diff --git a/.changeset/selfish-monkeys-joke.md b/.changeset/selfish-monkeys-joke.md new file mode 100644 index 00000000000..bb35077d74b --- /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 diff --git a/packages/adp-tooling/src/btp/api.ts b/packages/adp-tooling/src/btp/api.ts index 4713e4e4571..27019c97189 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. @@ -27,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) })); } } @@ -59,7 +60,43 @@ 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; } } + +/** + * 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 instanceof Error ? e.message : String(e) })); + } +} diff --git a/packages/adp-tooling/src/cf/project/yaml.ts b/packages/adp-tooling/src/cf/project/yaml.ts index b9b12ef0b91..014cfa07c53 100644 --- a/packages/adp-tooling/src/cf/project/yaml.ts +++ b/packages/adp-tooling/src/cf/project/yaml.ts @@ -16,7 +16,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 { getServiceKeyDestinations } from '../app/discovery'; @@ -44,6 +44,55 @@ 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', + 'service-name': connectivityResourceName + } + }); + + memFs.write(mtaYamlPath, yaml.dump(yamlContent, { lineWidth: -1 })); +} + /** * Gets the SAP Cloud Service. * 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..58c23c9b98d --- /dev/null +++ b/packages/adp-tooling/src/cf/services/destinations.ts @@ -0,0 +1,49 @@ +import * as path from 'node:path'; + +import type { Destinations } from '@sap-ux/btp-utils'; + +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'; + +/** + * Finds the name of the destination service instance declared in the MTA project's mta.yaml. + * + * @param {string} projectPath - The root path of the app project. + * @returns {string} The CF service instance name. + * @throws {Error} When 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 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 getBtpDestinations(projectPath: string): Promise { + 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 a23d5f42efb..7d337ca6d3c 100644 --- a/packages/adp-tooling/src/cf/services/index.ts +++ b/packages/adp-tooling/src/cf/services/index.ts @@ -1,4 +1,5 @@ export * from './api'; export * from './ssh'; export * from './cli'; +export * from './destinations'; export * from './manifest'; 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..286a5da57bf 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 'node:path'; + import type { ListQuestion, InputQuestion, @@ -6,17 +9,28 @@ 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, 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 { getBtpDestinations } from '../../cf/services/destinations'; import { ChangeType, NamespacePrefix, + ServiceType, + type DescriptorVariant, type NewModelAnswers, + type NewModelData, type ManifestChangeProperties, - FlexLayer + FlexLayer, + type XsApp, + type XsAppRoute } from '../../types'; import { isCFEnvironment } from '../../base/cf'; +import { getAdpConfig } from '../../base/helper'; import { validateEmptyString, validateEmptySpaces, @@ -27,9 +41,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 } ]; /** @@ -48,6 +80,10 @@ function validatePromptInput(value: string): boolean | string { } } + if (!/[a-zA-Z0-9]$/.test(value)) { + return t('validators.errorInputMustEndWithAlphanumeric'); + } + return true; } @@ -95,14 +131,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 +146,7 @@ function validatePromptODataName( } if (isCustomerBase) { - validationResult = validateCustomerValue(value, 'prompts.oDataServiceNameLabel'); + validationResult = validateCustomerValue(value, 'prompts.modelAndDatasourceNameLabel'); if (typeof validationResult === 'string') { return validationResult; } @@ -122,100 +156,116 @@ 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; - } +async function getAbapServiceUrl(projectPath: string): Promise { + try { + const { target } = (await getAdpConfig(projectPath, 'ui5.yaml')) as any; - if (!isDataSourceURI(value)) { - return t('validators.errorInvalidDataSourceURI'); + if (!target) { + return undefined; + } + + if (target.url) { + return target.url; + } + if (target.destination) { + const destinations = await listDestinations(); + return destinations[target.destination]?.Host; + } + } catch { + // Message will not be shown } + return undefined; +} - return true; +/** + * Fetches destination choices for CF environments. + * Returns the choices and a generic UI error message if the fetch fails, logging the original error. + * + * @param {string} projectPath - The root path of the project. + * @param {ToolsLogger} [logger] - Optional logger for error details. + * @returns {Promise<{ choices: { name: string; value: Destination }[]; error?: string }>} 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 getBtpDestinations(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') }; + } } /** @@ -223,74 +273,109 @@ function validatePromptURI(value: string): boolean | string { * * @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); + 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; + + if (isCFEnv) { + ({ choices: destinationChoices, error: destinationError } = await getDestinationChoices(projectPath, logger)); + } + + 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: '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: (): { name: string; value: Destination }[] => destinationChoices ?? [], + 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 && readXsAppRoutes(projectPath).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 - } - } as ListQuestion, + additionalMessages: (uri: unknown, previousAnswers?: NewModelAnswers): IMessageSeverity | undefined => + buildResultingUrlMessage('prompts.resultingServiceUrl', uri, previousAnswers) + } 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 +383,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 +392,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', @@ -333,7 +405,9 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom 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', @@ -348,3 +422,47 @@ export async function getPrompts(projectPath: string, layer: UI5FlexLayer): Prom } 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 ? undefined : modelAndDatasourceName, + 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 1635025011b..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 } 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/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index c587fa46b83..f39e52e733f 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -13,10 +13,20 @@ "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 a destination for the 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}}", + "resultingAnnotationUrl": "Resulting Annotation URL: {{url}}", + "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", @@ -67,7 +77,9 @@ "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.", "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.", @@ -115,6 +127,10 @@ "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.", + "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.", "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}}" diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index b30d624a4a4..71e986a9182 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; @@ -529,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', @@ -536,11 +538,20 @@ 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. */ 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', @@ -648,15 +659,25 @@ 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. */ + 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; /** 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 for absent for HTTP service type. */ + modelName?: string; + /** Version of OData used. Optional for HTTP service type. */ + version?: string; /** Settings for the OData service model. */ modelSettings?: string; }; @@ -676,23 +697,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 +913,8 @@ export interface Uaa { url: string; } +export type CfDestinationServiceCredentials = { uri: string; uaa: Uaa } | ({ uri: string } & Uaa); + 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 650eb51f6ce..05dd75505d7 100644 --- a/packages/adp-tooling/src/writer/cf.ts +++ b/packages/adp-tooling/src/writer/cf.ts @@ -6,20 +6,15 @@ import { create, type Editor } from 'mem-fs-editor'; import { type ToolsLogger } from '@sap-ux/logger'; import { readUi5Yaml } from '@sap-ux/project-access'; -import { - adjustMtaYaml, - getAppHostIds, - getOrCreateServiceInstanceKeys, - getCfUi5AppInfo, - getProjectNameForXsSecurity -} from '../cf'; +import { adjustMtaYaml, getOrCreateServiceInstanceKeys, 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, CfUi5AppInfo } 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 { getBaseAppId } from '../base/helper'; +import { getAppHostIds } from '../cf/app/discovery'; /** * Writes the CF adp-project template to the mem-fs-editor instance. 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..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 @@ -1,11 +1,16 @@ +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'; +import { ensureTunnelAppExists, DEFAULT_TUNNEL_APP_NAME } from '../../../cf/services/ssh'; type NewModelContent = { - model: { + model?: { [key: string]: { settings?: object; dataSource: string; @@ -36,31 +41,41 @@ 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, serviceType } = data; + const isHttp = serviceType === ServiceType.HTTP; + + const uri = isCloudFoundry ? `${service.name.replaceAll('.', '/')}${service.uri}` : service.uri; + + const dataSourceEntry: DataSourceItem = { + uri, + type: isHttp ? 'http' : '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 - } - } - }, - model: { - [service.modelName]: { - dataSource: service.name - } + [service.name]: dataSourceEntry } }; - if (service.modelSettings && service.modelSettings.length !== 0) { - content.model[service.modelName].settings = parseStringToObject(service.modelSettings); + if (!isHttp && service.modelName) { + content.model = { + [service.modelName]: { + dataSource: service.name, + ...(service.modelSettings?.length ? { 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' @@ -82,9 +97,43 @@ 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); + + if (data.isCloudFoundry) { + this.writeXsAppRoute(data); + } + + if (data.isOnPremiseDestination) { + await addConnectivityServiceToMta(dirname(this.projectPath), this.fs); + await ensureTunnelAppExists(DEFAULT_TUNNEL_APP_NAME, data.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.replaceAll('.', '/')}${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/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/project/yaml.test.ts b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts index 56c91cf4963..f9da396b2a6 100644 --- a/packages/adp-tooling/test/unit/cf/project/yaml.test.ts +++ b/packages/adp-tooling/test/unit/cf/project/yaml.test.ts @@ -9,11 +9,12 @@ import { getSAPCloudService, getRouterType, getAppParamsFromUI5Yaml, - adjustMtaYaml + adjustMtaYaml, + 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'; jest.mock('fs', () => ({ @@ -23,7 +24,9 @@ jest.mock('fs', () => ({ const mockExistsSync = existsSync as jest.MockedFunction; 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', () => ({ @@ -32,6 +35,10 @@ 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< + typeof getOrCreateServiceInstanceKeys +>; const mockGetYamlContent = getYamlContent as jest.MockedFunction; const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< typeof getProjectNameForXsSecurity @@ -873,4 +880,103 @@ describe('YAML Project Functions', () => { expect(mockMemFs.write).toHaveBeenCalledWith(mtaYamlPath, expect.any(String)); }); }); + + 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(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 + ); + }); + + test('should not create service 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 not create service 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 not create service 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 modify 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 3d429c72920..a9be931e722 100644 --- a/packages/adp-tooling/test/unit/cf/services/api.test.ts +++ b/packages/adp-tooling/test/unit/cf/services/api.test.ts @@ -14,9 +14,9 @@ import { createServiceInstance, getServiceNameByTags, createServices, + getOrCreateServiceInstanceKeys, getServiceTags, - getServiceKeyCredentialsWithTags, - getOrCreateServiceInstanceKeys + getServiceKeyCredentialsWithTags } from '../../../../src/cf/services/api'; import { initI18n, t } from '../../../../src/i18n'; import { isLoggedInCf } from '../../../../src/cf/core/auth'; 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..cbeb3264a3b --- /dev/null +++ b/packages/adp-tooling/test/unit/cf/services/destinations.test.ts @@ -0,0 +1,142 @@ +import { join, dirname } from 'node:path'; +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'; +import { initI18n, t } from '../../../../src/i18n'; + +jest.mock('@sap-ux/btp-utils'); + +jest.mock('../../../../src/cf/services/api', () => ({ + getOrCreateServiceInstanceKeys: jest.fn() +})); + +jest.mock('../../../../src/btp/api', () => ({ + listBtpDestinations: jest.fn() +})); + +jest.mock('../../../../src/cf/project/yaml-loader', () => ({ + getYamlContent: jest.fn() +})); + +const getOrCreateServiceInstanceKeysMock = getOrCreateServiceInstanceKeys as jest.Mock; +const listBtpDestinationsMock = listBtpDestinations as jest.Mock; +const getYamlContentMock = getYamlContent as jest.Mock; + +const mockProjectPath = join('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('getBtpDestinations', () => { + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 getBtpDestinations(mockProjectPath); + + expect(getYamlContentMock).toHaveBeenCalledWith(join(dirname(mockProjectPath), '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 () => { + getYamlContentMock.mockReturnValue({ + ...mockMtaYaml, + resources: [ + { + name: 'test-project-uaa', + type: 'org.cloudfoundry.managed-service', + parameters: { service: 'xsuaa', 'service-plan': 'application' } + } + ] + }); + + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( + t('error.destinationServiceNotFoundInMtaYaml') + ); + + expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); + }); + + it('should throw an error when mta.yaml cannot be read', async () => { + getYamlContentMock.mockImplementation(() => { + throw new Error('File not found'); + }); + + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow('File not found'); + + expect(getOrCreateServiceInstanceKeysMock).not.toHaveBeenCalled(); + }); + + 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' } + }); + + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( + t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) + ); + + expect(listBtpDestinationsMock).not.toHaveBeenCalled(); + }); + + it('should throw an error when getOrCreateServiceInstanceKeys does not return any keys', async () => { + getYamlContentMock.mockReturnValue(mockMtaYaml); + getOrCreateServiceInstanceKeysMock.mockResolvedValue(null); + + await expect(getBtpDestinations(mockProjectPath)).rejects.toThrow( + t('error.noServiceKeysFoundForDestination', { serviceInstanceName: 'test-project-destination' }) + ); + + expect(listBtpDestinationsMock).not.toHaveBeenCalled(); + }); +}); 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..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 @@ -1,13 +1,25 @@ 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 { getPrompts } from '../../../../src/prompts/add-new-model'; +import { getAdpConfig } from '../../../../src/base/helper'; +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, 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'; 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 = getBtpDestinations as jest.Mock; +const isOnPremiseDestinationMock = isOnPremiseDestination 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 +30,26 @@ 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(), + isOnPremiseDestination: jest.fn() +})); + +jest.mock('../../../../src/cf/services/destinations', () => ({ + getBtpDestinations: 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 +69,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 +99,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,67 +111,52 @@ 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 Data Source 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('The input must end with an alphanumeric character.'); }); it('should return error message when validating service name prompt and has special characters', async () => { 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( - 'general.invalidValueForSpecialChars' - ); + expect(validation?.('customer.testName@')).toBe('general.invalidValueForSpecialChars'); }); - it('should return error message when validating service name prompt has content duplication', async () => { - jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); - + 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 === '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( - 'An OData annotation or service with the same name was already added to the project. Rename and try again.' - ); + 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 has name duplication', async () => { + 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'); - 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', { - 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.' + expect(validation?.('customer.testName')).toBe( + 'An OData annotation or service with the same name was already added to the project. Rename and try again.' ); }); @@ -167,251 +191,423 @@ 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'); + 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 dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/odata/v4/example' })).toBe('4.0'); + 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 default value for odata version when uri answer is not present', async () => { - isCFEnvironmentMock.mockReturnValueOnce(true).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 result = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: undefined })).toBe('2.0'); + 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 default value for odata version based on uri answer in CF environment', async () => { - isCFEnvironmentMock.mockReturnValueOnce(true).mockReturnValueOnce(false); + it('should return information message with resulting service URL for CF project using selected destination', async () => { + isCFEnvironmentMock.mockResolvedValue(true); - const result = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + 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 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 undefined from additionalMessages when uri is invalid', async () => { + jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); + getAdpConfigMock.mockResolvedValue({ target: { url: 'https://abap.example.com' } }); - const dafaultFn = result.find((prompt) => prompt.name === 'version')?.default; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'uri')?.additionalMessages as Function; - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/odata/v4/' })).toBe('4.0'); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('not-a-valid-uri', undefined); + expect(result).toBeUndefined(); }); - 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; + 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; - expect(typeof dafaultFn).toBe('function'); - expect(dafaultFn({ uri: '/sap/opu/odata4/' })).toBe('4.0'); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/odata/v4/', undefined); + expect(result).toBeUndefined(); }); - it('should return true when validating model name prompt', async () => { - const hasContentDuplicationSpy = jest.spyOn(validators, 'hasContentDuplication'); - + it('should return true when validating model settings prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const validation = prompts.find((p) => p.name === 'modelSettings')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName')).toBe(true); - expect(hasContentDuplicationSpy).toHaveBeenCalledWith('customer.testName', 'model', []); + expect(validation?.('"key": "value"')).toBe(true); }); - it('should return error message when validating model name prompt without "customer." prefix', async () => { - jest.spyOn(validators, 'hasCustomerPrefix').mockReturnValueOnce(false); + it('should return true when validating model settings prompt with empty value', async () => { + jest.spyOn(validators, 'validateEmptyString').mockReturnValueOnce('general.inputCannotBeEmpty'); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const validation = prompts.find((p) => p.name === 'modelSettings')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('testName')).toBe("OData Service SAPUI5 Model Name must start with 'customer.'."); + expect(validation?.('')).toBe(true); }); - it('should return error message when validating model name contains only "customer."', async () => { + it('should return error message when validating model settings prompt with incorrect input', async () => { + jest.spyOn(validators, 'validateJSON').mockReturnValueOnce('general.invalidJSON'); + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const validation = prompts.find((p) => p.name === 'modelSettings')?.validate; 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(validation?.('{"key": "value"}')).toBe('general.invalidJSON'); }); - 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 true when validating data source uri prompt', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('customer.testName@')).toBe('general.invalidValueForSpecialChars'); + expect(validation?.('/sap/opu/odata4Ann/')).toBe(true); }); - it('should return error message when validating model name prompt has content duplication', async () => { - jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); + it('should return error message when data source uri is not valid uri', async () => { + jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - - const validation = prompts.find((p) => p.name === 'modelName')?.validate; + const validation = prompts.find((p) => p.name === 'dataSourceURI')?.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(validation?.('/sap/opu /odata4Ann/')).toBe(i18n.t('validators.errorInvalidDataSourceURI')); }); - it('should return true when validating model settings prompt', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + 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 validation = prompts.find((p) => p.name === 'modelSettings')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('"key": "value"')).toBe(true); + 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 true when validating model settings prompt with empty value', async () => { - jest.spyOn(validators, 'validateEmptyString').mockReturnValueOnce('general.inputCannotBeEmpty'); + 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; - const validation = prompts.find((p) => p.name === 'modelSettings')?.validate; - - expect(typeof validation).toBe('function'); - expect(validation?.('')).toBe(true); + 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 error message when validating model settings prompt with incorrect input', async () => { - jest.spyOn(validators, 'validateJSON').mockReturnValueOnce('general.invalidJSON'); + 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 validation = prompts.find((p) => p.name === 'modelSettings')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('{"key": "value"}')).toBe('general.invalidJSON'); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('not-a-valid-uri', undefined); + expect(result).toBeUndefined(); }); - it('should return error message when validating data source name prompt without "customer." prefix', async () => { - jest.spyOn(validators, 'hasCustomerPrefix').mockReturnValueOnce(false); - + it('should return undefined from dataSourceURI additionalMessages when no destination URL is available', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + const additionalMessages = prompts.find((p) => p.name === 'dataSourceURI')?.additionalMessages as Function; - expect(typeof validation).toBe('function'); - expect(validation?.('testName', { name: 'testName' } as NewModelAnswers)).toBe( - "OData Annotation Data Source Name must start with 'customer.'." - ); + expect(typeof additionalMessages).toBe('function'); + const result = await additionalMessages('/sap/opu/odata4Ann/', undefined); + expect(result).toBeUndefined(); }); - it('should return error message when validating data source name prompt with only "customer." prefix', async () => { + 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(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?.('"key": "value"')).toBe(true); }); - it('should return true when validating data source name prompt', async () => { - const hasContentDuplicationSpy = jest.spyOn(validators, 'hasContentDuplication'); - + it('should display the dataSourceURI and annotationSettings prompts when addAnnotationMode is true', async () => { const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const validation = prompts.find((p) => p.name === 'dataSourceName')?.validate; + const answers = { addAnnotationMode: true } as NewModelAnswers; - expect(typeof validation).toBe('function'); - expect(hasContentDuplicationSpy).toHaveBeenCalledWith('customer.testName', 'dataSource', []); - expect(validation?.('customer.testName', { name: 'otherName' } as NewModelAnswers)).toBe(true); - }); + const dataSourceURIPromptWhen = prompts.find((p) => p.name === 'dataSourceURI')?.when as Function; + const annotationSettingsPromptWhen = prompts.find((p) => p.name === 'annotationSettings')?.when as Function; - it('should return error message when validating data source name prompt and has special characters', async () => { - jest.spyOn(validators, 'validateSpecialChars').mockReturnValueOnce('general.invalidValueForSpecialChars'); + expect(typeof dataSourceURIPromptWhen).toBe('function'); + expect(typeof annotationSettingsPromptWhen).toBe('function'); + expect(dataSourceURIPromptWhen(answers)).toBe(true); + expect(annotationSettingsPromptWhen(answers)).toBe(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 validation).toBe('function'); - expect(validation?.('customer.testName@', { name: 'otherName' } as NewModelAnswers)).toBe( - 'general.invalidValueForSpecialChars' - ); + expect(typeof messageFn).toBe('function'); + expect(messageFn({ serviceType: 'HTTP' })).toBe(i18n.t('prompts.datasourceNameLabel')); }); - it('should return error message when validating data source name prompt has content duplication', async () => { - jest.spyOn(validators, 'hasContentDuplication').mockReturnValueOnce(true); + 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 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: 'otherName' } as NewModelAnswers)).toBe( - 'An OData annotation or service with the same name was already added to the project. 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 error message when validating data source name prompt has name duplication', 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 === '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(odataAnswers)).toBe(true); + expect(addAnnotationModeWhen(odataAnswers)).toBe(true); }); - it('should return true when validating data source uri prompt', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + 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 validation = prompts.find((p) => p.name === 'dataSourceURI')?.validate; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); + const validation = prompts.find((p) => p.name === 'uri')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('/sap/opu/odata4Ann/')).toBe(true); + expect(validation?.('/sap/opu/odata/v4/')).toBe(i18n.t('validators.errorRouteAlreadyExists')); }); - it('should return error message when data source uri is not valid uri', async () => { - jest.spyOn(validators, 'isDataSourceURI').mockReturnValueOnce(false); + 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 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(true); }); - it('should return true when validating annotation settings prompt', 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 validation = prompts.find((p) => p.name === 'annotationSettings')?.validate; + const validation = prompts.find((p) => p.name === 'uri')?.validate; expect(typeof validation).toBe('function'); - expect(validation?.('"key": "value"')).toBe(true); + validation?.('/sap/opu/odata/v4/'); + + expect(readFileSyncMock).not.toHaveBeenCalledWith(expect.stringContaining('xs-app.json'), expect.anything()); }); - it('should display the dataSourceName, dataSourceURI, and annotationSettings prompts when addAnnotationMode is true', async () => { - const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE'); - const answers = { addAnnotationMode: true } as NewModelAnswers; + 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 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; + const logger = { error: jest.fn() } as Partial as ToolsLogger; + const prompts = await getPrompts(mockPath, 'CUSTOMER_BASE', logger); - 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); + 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')); + }); +}); + +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/cf.test.ts b/packages/adp-tooling/test/unit/writer/cf.test.ts index 1e49c67ac27..3b64cb4d890 100644 --- a/packages/adp-tooling/test/unit/writer/cf.test.ts +++ b/packages/adp-tooling/test/unit/writer/cf.test.ts @@ -2,33 +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, setupCfPreview } from '../../../src/writer/cf'; import { AppRouterType, FlexLayer, type CfAdpWriterConfig, type CfUi5AppInfo, type CfConfig } from '../../../src/types'; -import { - getAppHostIds, - getOrCreateServiceInstanceKeys, - getCfUi5AppInfo, - getProjectNameForXsSecurity -} from '../../../src/cf'; -import { getBaseAppId } from '../../../src/base/helper'; +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'; +import { getAppHostIds } from '../../../src/cf/app/discovery'; + jest.mock('../../../src/cf'); -jest.mock('../../../src/base/helper'); jest.mock('../../../src/base/project-builder'); jest.mock('@sap-ux/project-access'); +jest.mock('../../../src/base/helper'); +jest.mock('../../../src/cf/app/discovery'); -const mockGetAppHostIds = getAppHostIds as jest.MockedFunction; const mockGetOrCreateServiceInstanceKeys = getOrCreateServiceInstanceKeys as jest.MockedFunction< typeof getOrCreateServiceInstanceKeys >; const mockGetCfUi5AppInfo = getCfUi5AppInfo as jest.MockedFunction; const mockGetBaseAppId = getBaseAppId as jest.MockedFunction; +const mockGetAppHostIds = getAppHostIds as jest.MockedFunction; const mockRunBuild = runBuild as jest.MockedFunction; const mockReadUi5Yaml = readUi5Yaml as jest.MockedFunction; const mockGetProjectNameForXsSecurity = getProjectNameForXsSecurity as jest.MockedFunction< @@ -215,6 +213,7 @@ describe('CF Writer', () => { await writeUi5AppInfo(projectDir, mockUi5AppInfo, mockLogger); const filePath = join(projectDir, 'ui5AppInfo.json'); + const { existsSync, readFileSync } = await import('node:fs'); expect(existsSync(filePath)).toBe(true); const content = JSON.parse(readFileSync(filePath, 'utf-8')); 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..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 @@ -1,3 +1,4 @@ +import { join } from 'node:path'; import type { Editor } from 'mem-fs-editor'; import { @@ -7,12 +8,14 @@ import { writeChangeToFile, getChange } from '../../../../../src/base/change-utils'; +import { addConnectivityServiceToMta } from '../../../../../src/cf/project/yaml'; import type { AnnotationsData, ComponentUsagesDataBase, ComponentUsagesDataWithLibrary, DataSourceData, NewModelData, + NewModelDataWithAnnotations, InboundData, DescriptorVariant } from '../../../../../src'; @@ -23,7 +26,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'), @@ -34,13 +37,23 @@ jest.mock('../../../../../src/base/change-utils', () => ({ writeChangeToFile: jest.fn() })); +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; 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', () => { @@ -216,19 +229,27 @@ 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 () => { - const mockData: NewModelData = { + const mockData: NewModelDataWithAnnotations = { variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V4, service: { name: 'ODataService', uri: '/sap/opu/odata/custom', - modelName: 'ODataModel', + modelName: 'ODataService', version: '4.0', modelSettings: '"someSetting": "someValue"' }, @@ -263,7 +284,7 @@ describe('NewModelWriter', () => { } }, 'model': { - 'ODataModel': { + 'ODataService': { 'dataSource': mockData.service.name, 'settings': { 'someSetting': 'someValue' @@ -276,6 +297,243 @@ describe('NewModelWriter', () => { expect(writeChangeToFolderMock).toHaveBeenCalledWith(mockProjectPath, expect.any(Object), expect.any(Object)); }); + + it('should omit the model block in HTTP service type scenario', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + serviceType: ServiceType.HTTP, + 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': 'http', + 'settings': {} + } + } + }, + ChangeType.ADD_NEW_DATA_SOURCE + ); + }); + + it('should construct CF change content with derived URI', 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' + } + }; + + 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' + } + } + }, + ChangeType.ADD_NEW_MODEL + ); + }); + + 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, + 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' + } + }; + + await writer.write(mockData); + + 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/(.*)', + 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, + serviceType: ServiceType.ODATA_V2, + 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(join(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, + serviceType: ServiceType.ODATA_V2, + 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, + serviceType: ServiceType.ODATA_V2, + 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(join('mock', 'project'), expect.any(Object)); + }); + + it('should not call addConnectivityServiceToMta when not in CF', async () => { + const mockData: NewModelData = { + variant: {} as DescriptorVariant, + serviceType: ServiceType.ODATA_V2, + isCloudFoundry: false, + 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, + serviceType: ServiceType.ODATA_V2, + 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', () => { @@ -350,7 +608,7 @@ describe('DataSourceWriter', () => { }); describe('InboundWriter', () => { - const mockProjectPath = '/mock/project/path'; + const mockProjectPath = join('mock', 'project', 'path'); let writer: InboundWriter; beforeEach(() => { @@ -391,13 +649,13 @@ 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); 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' }) }), {} ); 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 2e7bdbeee87..da007f2e0d7 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..2f1d1ac7a5d 100644 --- a/packages/create/src/cli/add/new-model.ts +++ b/packages/create/src/cli/add/new-model.ts @@ -1,7 +1,6 @@ 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, createNewModelData } from '@sap-ux/adp-tooling'; import { promptYUIQuestions } from '../../common'; import { getLogger, traceChanges } from '../../tracing'; @@ -16,7 +15,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,18 +38,15 @@ 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); - 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, ChangeType.ADD_NEW_MODEL, - createNewModelData(variant, answers) + await createNewModelData(basePath, variant, answers, logger) ); if (!simulate) { @@ -64,31 +59,3 @@ async function addNewModel(basePath: string, simulate: boolean): Promise { logger.debug(error); } } - -/** - * Returns the writer data for the new model change. - * - * @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. - */ -function createNewModelData(variant: DescriptorVariant, answers: NewModelAnswers): NewModelData { - const { name, uri, modelName, version, modelSettings, addAnnotationMode } = answers; - return { - variant, - service: { - name, - uri, - modelName, - version, - modelSettings - }, - ...(addAnnotationMode && { - annotation: { - dataSourceName: answers.dataSourceName, - 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 bf9b5a9a766..79df84fd68b 100644 --- a/packages/create/test/unit/cli/add/new-model.test.ts +++ b/packages/create/test/unit/cli/add/new-model.test.ts @@ -5,7 +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 { generateChange, getPromptsForNewModel, createNewModelData } from '@sap-ux/adp-tooling'; import * as common from '../../../../src/common'; import * as tracer from '../../../../src/tracing/trace'; @@ -21,43 +21,37 @@ 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 = { - 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"' -}; +const mockNewModelData = { variant: descriptorVariant, serviceType: 'OData v2', isCloudFoundry: false }; -jest.mock('fs', () => ({ - ...jest.requireActual('fs'), +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), readFileSync: jest.fn() })); @@ -68,7 +62,8 @@ 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(), + createNewModelData: jest.fn() })); const getArgv = (...arg: string[]) => ['', '', 'model', ...arg]; @@ -88,6 +83,7 @@ describe('add/model', () => { jest.spyOn(logger, 'getLogger').mockImplementation(() => loggerMock); jest.spyOn(projectAccess, 'getAppType').mockResolvedValue('Fiori Adaptation'); readFileSyncMock.mockReturnValue(JSON.stringify(descriptorVariant)); + createNewModelDataMock.mockResolvedValue(mockNewModelData); traceSpy = jest.spyOn(tracer, 'traceChanges').mockResolvedValue(); }); @@ -95,20 +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', { - service: mockAnswers, - variant: descriptorVariant - }); + 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'); @@ -116,25 +110,36 @@ 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', { - service: mockService, - annotation: mockAnnotation, - variant: descriptorVariant - }); + expect(createNewModelDataMock).toHaveBeenCalledWith( + appRoot, + descriptorVariant, + mockAnswersWithAnnotation, + loggerMock + ); + expect(generateChangeMock).toHaveBeenCalledWith(appRoot, 'appdescr_ui5_addNewModel', mockNewModelData); + }); + + test('should pass CF answers to createNewModelData', async () => { + jest.spyOn(common, 'promptYUIQuestions').mockResolvedValue(mockCFAnswers); + + const command = new Command('model'); + addNewModelCommand(command); + await command.parseAsync(getArgv(appRoot)); + + expect(loggerMock.debug).not.toHaveBeenCalled(); + 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', { - service: mockAnswers, - variant: descriptorVariant - }); + 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/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..c6edd8a41a7 100644 --- a/packages/generator-adp/src/add-new-model/index.ts +++ b/packages/generator-adp/src/add-new-model/index.ts @@ -1,6 +1,18 @@ 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, DescriptorVariant, CfConfig } from '@sap-ux/adp-tooling'; +import { + generateChange, + ChangeType, + getPromptsForNewModel, + getVariant, + createNewModelData, + isCFEnvironment, + isLoggedInCf, + loadCfConfig, + extractCfBuildTask, + readUi5Config +} from '@sap-ux/adp-tooling'; +import { setYeomanEnvConflicterForce } from '@sap-ux/fiori-generator-shared'; import { GeneratorTypes } from '../types'; import { initI18n, t } from '../utils/i18n'; @@ -19,6 +31,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. */ @@ -39,8 +55,18 @@ class AddNewModelGenerator extends SubGeneratorBase { async initializing(): Promise { await initI18n(); + setYeomanEnvConflicterForce(this.env, true); 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')); + } + await this._checkCfTargetMismatch(); + } + this._registerPrompts( new Prompts([ { name: t('yuiNavSteps.addNewModelName'), description: t('yuiNavSteps.addNewModelDescr') } @@ -55,13 +81,37 @@ 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); 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)}`); } @@ -69,7 +119,7 @@ class AddNewModelGenerator extends SubGeneratorBase { await generateChange( this.projectPath, ChangeType.ADD_NEW_MODEL, - this._createNewModelData(), + await createNewModelData(this.projectPath, this.variant, this.answers, this.logger), this.fs ); this.logger.log('Change written to changes folder'); @@ -78,32 +128,6 @@ class AddNewModelGenerator extends SubGeneratorBase { end(): void { this.logger.log('Successfully created change!'); } - - /** - * Creates the new model data. - * - * @returns {NewModelData} The new model data. - */ - private _createNewModelData(): NewModelData { - const { name, uri, modelName, version, modelSettings, addAnnotationMode } = this.answers; - return { - variant: this.variant, - service: { - name, - uri, - modelName, - version, - modelSettings - }, - ...(addAnnotationMode && { - annotation: { - dataSourceName: this.answers.dataSourceName, - dataSourceURI: this.answers.dataSourceURI, - settings: this.answers.annotationSettings - } - }) - }; - } } export = AddNewModelGenerator; diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 0e6d0b7dd9c..71cf42a93f8 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", @@ -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/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 5dd4546c9fe..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 @@ -2,19 +2,46 @@ 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 type { NewModelAnswers, DescriptorVariant } from '@sap-ux/adp-tooling'; +import { + ChangeType, + generateChange, + getVariant, + isCFEnvironment, + isLoggedInCf, + loadCfConfig, + createNewModelData, + readUi5Config, + extractCfBuildTask +} from '@sap-ux/adp-tooling'; +import type { + NewModelAnswers, + NewModelData, + DescriptorVariant, + UI5YamlCustomTaskConfiguration +} from '@sap-ux/adp-tooling'; 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(), + isLoggedInCf: jest.fn(), + loadCfConfig: jest.fn(), + createNewModelData: jest.fn(), + readUi5Config: jest.fn(), + extractCfBuildTask: jest.fn() })); const generateChangeMock = generateChange as jest.MockedFunction; 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 createNewModelDataMock = createNewModelData as jest.MockedFunction; +const readUi5ConfigMock = readUi5Config as jest.MockedFunction; +const extractCfBuildTaskMock = extractCfBuildTask as jest.MockedFunction; const variant = { reference: 'customer.adp.variant', @@ -24,20 +51,42 @@ 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' }; +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 +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(() => { jest.clearAllMocks(); }); @@ -57,20 +106,44 @@ describe('AddNewModelGenerator', () => { await expect(runContext.run()).resolves.not.toThrow(); - expect(generateChangeMock).toHaveBeenCalledWith( + expect(createNewModelDataMock).toHaveBeenCalledWith( tmpDir, - ChangeType.ADD_NEW_MODEL, + variant, expect.objectContaining({ - service: { - name: answers.name, - uri: answers.uri, - modelName: answers.modelName, - version: answers.version, - modelSettings: answers.modelSettings - } + 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 () => { + 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(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 () => { @@ -96,4 +169,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(); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35ecb0220e6..6550d80a196 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,7 +119,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) @@ -1541,6 +1541,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 @@ -28134,14 +28137,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) @@ -28154,7 +28158,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 @@ -28165,7 +28169,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 @@ -28176,6 +28180,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