diff --git a/.changeset/two-dragons-smoke.md b/.changeset/two-dragons-smoke.md new file mode 100644 index 00000000000..ad8967b298d --- /dev/null +++ b/.changeset/two-dragons-smoke.md @@ -0,0 +1,7 @@ +--- +'@sap-ux/generator-adp': minor +'@sap-ux/axios-extension': patch +'@sap-ux/adp-tooling': patch +--- + +feat(generator-adp): Developer taking over Key-User changes diff --git a/packages/adp-tooling/src/base/change-utils.ts b/packages/adp-tooling/src/base/change-utils.ts index 085dce3104d..7c6f8dcb43f 100644 --- a/packages/adp-tooling/src/base/change-utils.ts +++ b/packages/adp-tooling/src/base/change-utils.ts @@ -5,6 +5,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { DirName, getWebappPath } from '@sap-ux/project-access'; import { + FlexLayer, TemplateFileName, type AnnotationsData, type ChangeType, @@ -12,7 +13,8 @@ import { type InboundContent, type ManifestChangeProperties, type PropertyValueType, - ChangeTypeMap + ChangeTypeMap, + type AdpWriterConfig } from '../types'; import { renderFile } from 'ejs'; @@ -76,6 +78,68 @@ export async function writeAnnotationChange( } } +/** + * Writes key-user change payloads to the generated adaptation project. Transforms key-user changes to a developer adaptation format. + * + * @param projectPath - Project root path. + * @param config - The writer configuration. + * @param fs - Yeoman mem-fs editor. + */ +export async function writeKeyUserChanges(projectPath: string, config: AdpWriterConfig, fs: Editor): Promise { + const changes = config.keyUserChanges; + if (!changes?.length) { + return; + } + + for (const entry of changes) { + if (!entry?.content) { + continue; + } + + const change = { ...(entry.content as Record) }; + if (!change['fileName']) { + continue; + } + + const transformedChange = transformKeyUserChangeForAdp(change, config.app.id, config.app.layer); + + await writeChangeToFolder(projectPath, transformedChange as unknown as ManifestChangeProperties, fs); + } +} + +/** + * Transforms a key-user change to a developer adaptation format. + * + * @param change - The key-user change from the backend. + * @param appId - The ID of the newly created Adaptation Project. + * @param layer - The layer of the change. + * @returns {Record} The transformed change object. + */ +export function transformKeyUserChangeForAdp( + change: Record, + appId: string, + layer: FlexLayer | undefined +): Record { + const transformed = { ...change }; + + transformed.layer = layer ?? FlexLayer.CUSTOMER_BASE; + transformed.reference = appId; + transformed.namespace = path.posix.join('apps', appId, DirName.Changes, '/'); + if (transformed.projectId) { + transformed.projectId = appId; + } + transformed.support ??= {}; + const supportObject = transformed.support as Record; + supportObject.generator = 'adp-key-user-converter'; + + delete transformed.adaptationId; + delete transformed.version; + delete transformed.context; + delete transformed.versionId; + + return transformed; +} + /** * Writes a given change object to a file within a specified folder in the project's 'changes' directory. * If an additional subdirectory is specified, the change file is written there. diff --git a/packages/adp-tooling/src/index.ts b/packages/adp-tooling/src/index.ts index 5d5ade4d294..ab9a978c293 100644 --- a/packages/adp-tooling/src/index.ts +++ b/packages/adp-tooling/src/index.ts @@ -10,6 +10,7 @@ export * from './base/helper'; export * from './base/constants'; export * from './base/project-builder'; export * from './base/abap/manifest-service'; +export { writeKeyUserChanges } from './base/change-utils'; export { promptGeneratorInput, PromptDefaults } from './base/prompt'; export * from './preview/adp-preview'; export * from './writer/cf'; diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 1d02455311a..d26394e7e70 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -1,7 +1,7 @@ import type { UI5FlexLayer, ManifestNamespace, Manifest, Package } from '@sap-ux/project-access'; import type { DestinationAbapTarget, UrlAbapTarget } from '@sap-ux/system-access'; import type { Adp, BspApp } from '@sap-ux/ui5-config'; -import type { AxiosRequestConfig, OperationsType } from '@sap-ux/axios-extension'; +import type { AxiosRequestConfig, KeyUserChangeContent, OperationsType } from '@sap-ux/axios-extension'; import type { Editor } from 'mem-fs-editor'; import type { Destination } from '@sap-ux/btp-utils'; import type { YUIQuestion } from '@sap-ux/inquirer-common'; @@ -109,6 +109,10 @@ export interface AdpWriterConfig { */ templatePathOverwrite?: string; }; + /** + * Optional: Key-user changes to be written to the project. + */ + keyUserChanges?: KeyUserChangeContent[]; } /** @@ -133,6 +137,7 @@ export interface AttributesAnswers { enableTypeScript: boolean; addDeployConfig?: boolean; addFlpConfig?: boolean; + importKeyUserChanges?: boolean; } export interface SourceApplication { diff --git a/packages/adp-tooling/src/writer/index.ts b/packages/adp-tooling/src/writer/index.ts index c9f0bab916e..6b6c40546a6 100644 --- a/packages/adp-tooling/src/writer/index.ts +++ b/packages/adp-tooling/src/writer/index.ts @@ -7,6 +7,7 @@ import { getI18nDescription, getI18nModels, writeI18nModels } from './i18n'; import { writeTemplateToFolder, writeUI5Yaml, writeUI5DeployYaml } from './project-utils'; import { FlexLayer, type AdpWriterConfig, type InternalInboundNavigation } from '../types'; import { getApplicationType } from '../source'; +import { writeKeyUserChanges } from '../base/change-utils'; const baseTmplPath = join(__dirname, '../../templates'); @@ -23,7 +24,8 @@ function setDefaults(config: AdpWriterConfig): AdpWriterConfig { ui5: { ...config.ui5 }, deploy: config.deploy ? { ...config.deploy } : undefined, options: { ...config.options }, - customConfig: config.customConfig ? { ...config.customConfig } : undefined + customConfig: config.customConfig ? { ...config.customConfig } : undefined, + keyUserChanges: config.keyUserChanges }; configWithDefaults.app.title ??= `Adaptation of ${config.app.reference}`; configWithDefaults.app.layer ??= FlexLayer.CUSTOMER_BASE; @@ -67,6 +69,7 @@ export async function generate(basePath: string, config: AdpWriterConfig, fs?: E writeTemplateToFolder(templatePath, join(basePath), fullConfig, fs); await writeUI5DeployYaml(basePath, fullConfig, fs); await writeUI5Yaml(basePath, fullConfig, fs); + await writeKeyUserChanges(basePath, fullConfig, fs); return fs; } diff --git a/packages/adp-tooling/src/writer/writer-config.ts b/packages/adp-tooling/src/writer/writer-config.ts index 8e841b36aff..6d580ae980c 100644 --- a/packages/adp-tooling/src/writer/writer-config.ts +++ b/packages/adp-tooling/src/writer/writer-config.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import type { ToolsLogger } from '@sap-ux/logger'; import type { Manifest, Package } from '@sap-ux/project-access'; -import type { AbapServiceProvider } from '@sap-ux/axios-extension'; +import type { AbapServiceProvider, KeyUserChangeContent } from '@sap-ux/axios-extension'; import type { AdpWriterConfig, @@ -68,6 +68,10 @@ export interface ConfigOptions { * The tools ID. */ toolsId: string; + /** + * Optional: Key-user changes to be written to the project. + */ + keyUserChanges?: KeyUserChangeContent[]; } /** @@ -94,7 +98,8 @@ export async function getConfig(options: ConfigOptions): Promise ({ @@ -522,4 +529,230 @@ describe('Change Utils', () => { ).rejects.toThrow('Failed to render annotation file'); }); }); + + describe('writeKeyUserChanges', () => { + const projectPath = 'project'; + const appId = 'sap.ui.demoapps.rta.freestyle'; + const supportId = '@sap-ux/adp-tooling'; + const writeJsonSpy = jest.fn(); + const mockFs = { writeJSON: writeJsonSpy } as unknown as Editor; + const mockConfig: AdpWriterConfig = { + app: { + id: appId, + layer: 'CUSTOMER_BASE' + } as App, + customConfig: { + adp: { + support: { + id: supportId, + version: '1.0.0' + } + } + } + } as AdpWriterConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return early if changes is undefined', async () => { + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: [] }, mockFs); + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: undefined }, mockFs); + expect(writeJsonSpy).not.toHaveBeenCalled(); + }); + + it('should skip entries without content', async () => { + const changes: KeyUserChangeContent[] = [ + { content: { fileName: 'test.change' } }, + {} as KeyUserChangeContent, + { content: { fileName: 'test2.change' } } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changes }, mockFs); + + expect(writeJsonSpy).toHaveBeenCalledTimes(2); + }); + + it('should skip entries without fileName', async () => { + const changes: KeyUserChangeContent[] = [ + { content: { fileName: 'test.change' } }, + { content: { changeType: 'page' } }, + { content: { fileName: 'test2.change' } } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changes }, mockFs); + + expect(writeJsonSpy).toHaveBeenCalledTimes(2); + }); + + it('should add texts to change if not already present', async () => { + const changes: KeyUserChangeContent[] = [ + { + content: { + fileName: 'id_123_page.change', + changeType: 'page' + }, + texts: { variantName: { value: 'Test Variant', type: 'XFLD' } } + } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changes }, mockFs); + + expect(writeJsonSpy).toHaveBeenCalledWith( + expect.stringContaining('id_123_page.change'), + expect.objectContaining({ + fileName: 'id_123_page.change', + changeType: 'page' + }) + ); + }); + + it('should write multiple changes', async () => { + const changes: KeyUserChangeContent[] = [ + { + content: { + fileName: 'id_123_page.change', + changeType: 'page' + } + }, + { + content: { + fileName: 'id_456_variant.change', + changeType: 'variant' + } + } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changes }, mockFs); + + expect(writeJsonSpy).toHaveBeenCalledTimes(2); + }); + + it('should transform key user changes for ADP format', async () => { + const changes: KeyUserChangeContent[] = [ + { + content: { + fileName: 'id_123_rename.change', + changeType: 'rename', + reference: 'sap.ui.demoapps.rta.freestyle', + layer: 'CUSTOMER', + namespace: 'apps/sap.ui.demoapps.rta.freestyle/changes/', + projectId: 'sap.ui.demoapps.rta.freestyle', + adaptationId: 'DEFAULT', + version: '1.0', + context: 'someContext', + versionId: 'someVersionId', + support: { + generator: 'sap.ui.rta.command' + } + } + } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changes }, mockFs); + + expect(writeJsonSpy).toHaveBeenCalledWith( + expect.stringContaining('id_123_rename.change'), + expect.objectContaining({ + fileName: 'id_123_rename.change', + reference: appId, + layer: 'CUSTOMER_BASE', + namespace: `apps/${appId}/changes/`, + projectId: appId, + support: expect.objectContaining({ + generator: 'adp-key-user-converter' + }) + }) + ); + const writtenChange = writeJsonSpy.mock.calls[0][1]; + expect(writtenChange).not.toHaveProperty('adaptationId'); + expect(writtenChange).not.toHaveProperty('version'); + expect(writtenChange).not.toHaveProperty('context'); + expect(writtenChange).not.toHaveProperty('versionId'); + }); + + it('should always set support.generator when support.id is provided', async () => { + const changesWithGenerator: KeyUserChangeContent[] = [ + { + content: { + fileName: 'id_123_with_generator.change', + changeType: 'rename', + support: { + generator: 'sap.ui.rta.command' + } + } + } + ]; + + const changesWithoutGenerator: KeyUserChangeContent[] = [ + { + content: { + fileName: 'id_456_without_generator.change', + changeType: 'rename' + } + } + ]; + + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changesWithGenerator }, mockFs); + await writeKeyUserChanges(projectPath, { ...mockConfig, keyUserChanges: changesWithoutGenerator }, mockFs); + + expect(writeJsonSpy).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('id_123_with_generator.change'), + expect.objectContaining({ + support: expect.objectContaining({ + generator: 'adp-key-user-converter' + }) + }) + ); + + const secondCallChange = writeJsonSpy.mock.calls[1][1]; + expect(secondCallChange.support).toBeDefined(); + expect(secondCallChange.support.generator).toBe('adp-key-user-converter'); + }); + }); + + describe('transformKeyUserChangeForAdp', () => { + const appId = 'sap.ui.demoapps.rta.freestyle'; + + it('should update support.generator when generator exists', () => { + const change = { + fileName: 'test.change', + changeType: 'rename', + support: { + generator: 'sap.ui.rta.command' + } + }; + + const result = transformKeyUserChangeForAdp(change, appId, FlexLayer.CUSTOMER_BASE); + + expect(result.support).toBeDefined(); + expect((result.support as Record)?.generator).toBe('adp-key-user-converter'); + }); + + it('should add support.generator when generator does not exist', () => { + const change = { + fileName: 'test.change', + changeType: 'rename', + support: {} + }; + + const result = transformKeyUserChangeForAdp(change, appId, FlexLayer.CUSTOMER_BASE); + + expect(result.support).toBeDefined(); + expect((result.support as Record)?.generator).toBe('adp-key-user-converter'); + }); + + it('should create support object and add generator support property', () => { + const change = { + fileName: 'test.change', + changeType: 'rename' + }; + + const result = transformKeyUserChangeForAdp(change, appId, FlexLayer.CUSTOMER_BASE); + + expect(result.support).toBeDefined(); + expect((result.support as Record)?.generator).toBe('adp-key-user-converter'); + }); + }); }); diff --git a/packages/adp-tooling/test/unit/writer/writer-config.test.ts b/packages/adp-tooling/test/unit/writer/writer-config.test.ts index e26cc06c5e6..b79783dc280 100644 --- a/packages/adp-tooling/test/unit/writer/writer-config.test.ts +++ b/packages/adp-tooling/test/unit/writer/writer-config.test.ts @@ -73,7 +73,8 @@ const baseConfig: ConfigOptions = { packageJson: { name: '@sap-ux/generator-adp', version: '0.0.1' } as Package, logger: {} as ToolsLogger, manifest, - toolsId: 'test-tools-id' + toolsId: 'test-tools-id', + keyUserChanges: [] }; describe('getConfig', () => { @@ -115,7 +116,8 @@ describe('getConfig', () => { shouldSetMinVersion: true, version: '1.135.0' }, - options: { fioriTools: true, enableTypeScript: false } + options: { fioriTools: true, enableTypeScript: false }, + keyUserChanges: [] }); }); }); diff --git a/packages/axios-extension/src/abap/index.ts b/packages/axios-extension/src/abap/index.ts index 2da8255af34..ae356b3cb2d 100644 --- a/packages/axios-extension/src/abap/index.ts +++ b/packages/axios-extension/src/abap/index.ts @@ -12,7 +12,12 @@ export { SystemInfo, AdaptationProjectType, Inbound, - InboundContent + InboundContent, + AdaptationsResponse, + AdaptationDescriptor, + KeyUserDataResponse, + KeyUserChangeContent, + FlexVersion } from './lrep-service'; export { AbapServiceProvider } from './abap-service-provider'; export { AppIndex, AppIndexService, Ui5AppInfo, Ui5AppInfoContent, App } from './app-index-service'; diff --git a/packages/axios-extension/src/abap/lrep-service.ts b/packages/axios-extension/src/abap/lrep-service.ts index 19410ac7032..2b73bd06dbc 100644 --- a/packages/axios-extension/src/abap/lrep-service.ts +++ b/packages/axios-extension/src/abap/lrep-service.ts @@ -111,6 +111,43 @@ export interface SystemInfo { inbounds?: Inbound[]; } +export interface AdaptationDescriptor { + id: string; + type?: string; + title?: string; + contexts?: Record; + createdBy?: string; + createdAt?: string; + changedBy?: string; + changedAt?: string; +} + +export interface AdaptationsResponse { + adaptations: AdaptationDescriptor[]; +} + +export interface KeyUserChangeContent { + content: Record; + texts?: Record; +} + +export interface KeyUserDataResponse { + contents: KeyUserChangeContent[]; +} + +export interface FlexVersion { + versionId: string; + isPublished: boolean; + title: string; + activatedAt: string; + activatedBy: string; + appdescrChangesHash: string; +} + +export interface FlexVersionsResponse { + versions: FlexVersion[]; +} + interface Language { sap: string; description: string; @@ -178,6 +215,26 @@ function isBuffer(input: string | Buffer): input is Buffer { */ const decodeUrlParams: CustomParamsSerializer = (params: URLSearchParams) => decodeURIComponent(params.toString()); +/** + * Transform the response data to a JSON object. + * + * @param data the response data + * @returns the transformed data + */ +function transformResponse(data: unknown): T { + if (typeof data === 'string') { + try { + return JSON.parse(data) as T; + } catch { + /** + * If parsing fails, return the original data to preserve error information for non-JSON error responses. + */ + return data as T; + } + } + return data as T; +} + /** * Path suffix for all DTA actions. */ @@ -344,6 +401,91 @@ export class LayeredRepositoryService extends Axios implements Service { } } + /** + * Lists context-based adaptations for the given application. + * + * @param {string} appId - Technical application id. + * @param {string} [version] - Optional version id. If not provided, uses the hardcoded default version. + * @returns {Promise} Adaptations sorted by priority. + */ + public async listAdaptations(appId: string, version?: string): Promise { + try { + const response = await this.get( + `/flex/apps/${encodeURIComponent(appId)}/adaptations/?version=${encodeURIComponent(version)}`, + { + transformResponse + } + ); + this.tryLogResponse(response, `Successfully fetched adaptations for app ${appId}`); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + this.tryLogResponse(error.response); + } + throw error; + } + } + + /** + * Retrieves key user changes for the given component and adaptation id. + * + * @param {string} componentId - Technical component id. + * @param {string} adaptationId - Adaptation identifier (DEFAULT for default). + * @returns {Promise} Key user change payload. + */ + public async getKeyUserData(componentId: string, adaptationId: string): Promise { + const params = new URLSearchParams(this.defaults?.params); + params.append('adaptationId', encodeURIComponent(adaptationId)); + try { + const response = await this.get(`/flex/keyuserdata/${componentId}`, { + params, + paramsSerializer: decodeUrlParams, + transformResponse + }); + this.tryLogResponse( + response, + `Successfully fetched key user data for component ${componentId} and ${adaptationId} adaptation.` + ); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + this.tryLogResponse(error.response); + } + throw error; + } + } + + /** + * Retrieves versions for the given component. + * + * @param {string} componentId - Technical component id. + * @param {number} [limit] - Optional limit for the number of versions to retrieve (default: 2). + * @param {string} [language] - Optional language code (default: EN). + * @returns {Promise} Flex versions response containing an array of versions. + */ + public async getFlexVersions( + componentId: string, + limit: number = 2, + language: string = 'EN' + ): Promise { + try { + const params = new URLSearchParams(this.defaults?.params); + params.append('sap-language', language); + params.append('limit', limit.toString()); + const response = await this.get(`/flex/versions/${encodeURIComponent(componentId)}`, { + params, + transformResponse + }); + this.tryLogResponse(response, `Successfully fetched flex versions for component ${componentId}`); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + this.tryLogResponse(error.response); + } + throw error; + } + } + /** * Get system info. * diff --git a/packages/axios-extension/test/abap/lrep-service.test.ts b/packages/axios-extension/test/abap/lrep-service.test.ts index fec5d11aa80..8b241200cb2 100644 --- a/packages/axios-extension/test/abap/lrep-service.test.ts +++ b/packages/axios-extension/test/abap/lrep-service.test.ts @@ -360,4 +360,161 @@ describe('LayeredRepositoryService', () => { } }); }); + + describe('listAdaptations', () => { + const appId = 'my.app.id'; + const adaptationsResponse = { + adaptations: [ + { id: 'CTX1', contexts: { role: ['/UI2/ADMIN'] } }, + { id: 'DEFAULT', type: 'DEFAULT' } + ] + }; + + test('should fetch adaptations for an app', async () => { + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/apps/${encodeURIComponent(appId)}/adaptations/`) + .query({ version: '0' }) + .reply(200, JSON.stringify(adaptationsResponse)); + + const result = await service.listAdaptations(appId, '0'); + expect(result).toEqual(adaptationsResponse); + }); + + test('should log response when AxiosError is thrown', async () => { + const mockAxiosError = { + isAxiosError: true, + response: { + status: 500, + data: '{ "code": "500", "message": "Internal Server Error" }', + headers: {}, + config: {} as any + }, + message: 'Request failed with status code 500' + } as AxiosError; + + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/apps/${encodeURIComponent(appId)}/adaptations/`) + .query({ version: '0' }) + .replyWithError(mockAxiosError); + + try { + await service.listAdaptations(appId, '0'); + fail('The function should have thrown an error.'); + } catch (error) { + expect(error).toBeDefined(); + expect(error.message).toBe(mockAxiosError.message); + } + }); + }); + + describe('getKeyUserData', () => { + const componentId = 'my.app.id'; + const adaptationId = 'DEFAULT'; + const keyUserResponse = { + contents: [ + { + content: { + changeType: 'page', + fileName: 'id_1_page', + namespace: 'apps/my.app.id/changes/', + reference: 'my.app.id' + } + } + ] + }; + + test('should fetch key user data for adaptation', async () => { + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/keyuserdata/${componentId}?adaptationId=${adaptationId}`) + .reply(200, JSON.stringify(keyUserResponse)); + + const result = await service.getKeyUserData(componentId, adaptationId); + expect(result).toEqual(keyUserResponse); + }); + + test('should log response when AxiosError is thrown', async () => { + const mockAxiosError = { + isAxiosError: true, + response: { + status: 404, + data: '{ "code": "404", "message": "Not Found" }', + headers: {}, + config: {} as any + }, + message: 'Request failed with status code 404' + } as AxiosError; + + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/keyuserdata/${componentId}?adaptationId=${adaptationId}`) + .replyWithError(mockAxiosError); + + try { + await service.getKeyUserData(componentId, adaptationId); + fail('The function should have thrown an error.'); + } catch (error) { + expect(error).toBeDefined(); + expect(error.message).toBe(mockAxiosError.message); + } + }); + }); + + describe('getFlexVersions', () => { + const componentId = 'my.app.id'; + const flexVersionsResponse = { + versions: [ + { + versionId: '1.0.0', + isPublished: true, + title: 'Version 1.0.0', + activatedAt: '2025-01-01T00:00:00Z', + activatedBy: 'USER1', + appdescrChangesHash: 'hash1' + }, + { + versionId: '2.0.0', + isPublished: false, + title: 'Version 2.0.0', + activatedAt: '2025-02-01T00:00:00Z', + activatedBy: 'USER2', + appdescrChangesHash: 'hash2' + } + ] + }; + + test('should fetch flex versions for component with default parameters', async () => { + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/versions/${encodeURIComponent(componentId)}`) + .query({ 'sap-language': 'EN', limit: '2' }) + .reply(200, JSON.stringify(flexVersionsResponse)); + + const result = await service.getFlexVersions(componentId); + expect(result).toEqual(flexVersionsResponse); + }); + + test('should log response when AxiosError is thrown', async () => { + const mockAxiosError = { + isAxiosError: true, + response: { + status: 500, + data: '{ "code": "500", "message": "Internal Server Error" }', + headers: {}, + config: {} as any + }, + message: 'Request failed with status code 500' + } as AxiosError; + + nock(server) + .get(`${LayeredRepositoryService.PATH}/flex/versions/${encodeURIComponent(componentId)}`) + .query({ 'sap-language': 'EN', limit: '2' }) + .replyWithError(mockAxiosError); + + try { + await service.getFlexVersions(componentId); + fail('The function should have thrown an error.'); + } catch (error) { + expect(error).toBeDefined(); + expect(error.message).toBe(mockAxiosError.message); + } + }); + }); }); diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 1e4924aec9d..044d22afa83 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -50,7 +50,8 @@ import { getWizardPages, updateCfWizardSteps, updateFlpWizardSteps, - updateWizardSteps + updateWizardSteps, + getKeyUserImportPage } from '../utils/steps'; import { addDeployGen, addExtProjectGen, addFlpGen } from '../utils/subgenHelpers'; import { getTemplatesOverwritePath } from '../utils/templates'; @@ -70,6 +71,7 @@ import { } from './types'; import { getProjectPathPrompt, getTargetEnvPrompt } from './questions/target-env'; import type { AdpTelemetryData } from '../types'; +import { KeyUserImportPrompter } from './questions/key-user'; const generatorTitle = 'Adaptation Project'; @@ -186,6 +188,10 @@ export default class extends Generator { * Telemetry collector instance. */ private telemetryCollector: TelemetryCollector; + /** + * Key-user import prompter instance. + */ + private keyUserPrompter?: KeyUserImportPrompter; /** * Creates an instance of the generator. @@ -298,7 +304,8 @@ export default class extends Generator { hasBaseAppInbounds: !!this.prompter.baseAppInbounds, hide: this.shouldCreateExtProject }, - addDeployConfig: { hide: this.shouldCreateExtProject || !this.isCustomerBase } + addDeployConfig: { hide: this.shouldCreateExtProject || !this.isCustomerBase }, + importKeyUserChanges: { hide: this.shouldCreateExtProject } }; const attributesQuestions = getPrompts(this.destinationPath(), promptConfig, options); this.attributeAnswers = await this.prompt(attributesQuestions); @@ -306,6 +313,20 @@ export default class extends Generator { // Steps need to be updated here to be available after back navigation in Yeoman UI. this._updateWizardStepsAfterNavigation(); + if (this.attributeAnswers.importKeyUserChanges) { + this.keyUserPrompter = new KeyUserImportPrompter( + this.systemLookup, + this.configAnswers.application.id, + this.prompter.provider, + this.configAnswers.system, + this.logger + ); + const keyUserQuestions = this.keyUserPrompter.getPrompts({ + keyUserSystem: { default: this.configAnswers.system } + }); + await this.prompt(keyUserQuestions); + } + this.logger.info(`Project Attributes: ${JSON.stringify(this.attributeAnswers, null, 2)}`); if (this.attributeAnswers.addDeployConfig) { const system = await this.systemLookup.getSystemByName(this.configAnswers.system); @@ -382,7 +403,8 @@ export default class extends Generator { layer: this.layer, packageJson, logger: this.toolsLogger, - toolsId: this.toolsId + toolsId: this.toolsId, + keyUserChanges: this.keyUserPrompter?.changes }); if (config.options) { @@ -537,7 +559,8 @@ export default class extends Generator { ui5ValidationCli: { hide: true }, enableTypeScript: { hide: true }, addFlpConfig: { hide: true }, - addDeployConfig: { hide: true } + addDeployConfig: { hide: true }, + importKeyUserChanges: { hide: true } }; const projectPath = this.destinationPath(); @@ -731,6 +754,13 @@ export default class extends Generator { ); } + updateWizardSteps( + this.prompts, + getKeyUserImportPage(), + t('yuiNavSteps.projectAttributesName'), + !!this.attributeAnswers.importKeyUserChanges + ); + if (!flpPagesExist) { updateFlpWizardSteps( !!this.prompter.baseAppInbounds, diff --git a/packages/generator-adp/src/app/questions/attributes.ts b/packages/generator-adp/src/app/questions/attributes.ts index 0df7215ad32..91c3a8201da 100644 --- a/packages/generator-adp/src/app/questions/attributes.ts +++ b/packages/generator-adp/src/app/questions/attributes.ts @@ -18,13 +18,14 @@ import type { TargetFolderPromptOptions, EnableTypeScriptPromptOptions, AddDeployConfigPromptOptions, - AddFlpConfigPromptOptions + AddFlpConfigPromptOptions, + ImportKeyUserChangesPromptOptions } from '../types'; import { t } from '../../utils/i18n'; import { attributePromptNames } from '../types'; import { getProjectNameTooltip } from './helper/tooltip'; import { getVersionAdditionalMessages } from './helper/additional-messages'; -import { updateWizardSteps, getDeployPage, updateFlpWizardSteps } from '../../utils/steps'; +import { updateWizardSteps, getDeployPage, updateFlpWizardSteps, getKeyUserImportPage } from '../../utils/steps'; import { getDefaultProjectName, getDefaultNamespace, getDefaultVersion } from './helper/default-values'; interface Config { @@ -74,6 +75,10 @@ export function getPrompts(path: string, config: Config, promptOptions?: Attribu prompts, isCloudProject, promptOptions?.[attributePromptNames.addFlpConfig] + ), + [attributePromptNames.importKeyUserChanges]: getImportKeyUserChangesPrompt( + prompts, + promptOptions?.[attributePromptNames.importKeyUserChanges] ) }; @@ -319,3 +324,29 @@ export function getFlpConfigPrompt( } } as ConfirmQuestion; } + +/** + * Creates the Import Key User Changes confirm prompt. + * + * @param {YeomanUiSteps} prompts - The Yeoman UI pages. + * @param {ImportKeyUserChangesPromptOptions} options - Optional prompt options. + * @returns {AttributesQuestion} The prompt configuration for copying key user changes. + */ +function getImportKeyUserChangesPrompt( + prompts: YeomanUiSteps, + options?: ImportKeyUserChangesPromptOptions +): AttributesQuestion { + return { + type: 'confirm', + name: attributePromptNames.importKeyUserChanges, + message: t('prompts.importKeyUserChangesLabel'), + default: options?.default ?? false, + guiOptions: { + breadcrumb: true + }, + validate: (value: boolean) => { + updateWizardSteps(prompts, getKeyUserImportPage(), t('yuiNavSteps.projectAttributesName'), value); + return true; + } + } as ConfirmQuestion; +} diff --git a/packages/generator-adp/src/app/questions/helper/choices.ts b/packages/generator-adp/src/app/questions/helper/choices.ts index 3593aec1b22..d3c8a5bcc27 100644 --- a/packages/generator-adp/src/app/questions/helper/choices.ts +++ b/packages/generator-adp/src/app/questions/helper/choices.ts @@ -1,5 +1,6 @@ -import { AppRouterType } from '@sap-ux/adp-tooling'; -import type { CFApp, SourceApplication } from '@sap-ux/adp-tooling'; +import { AppRouterType, getEndpointNames } from '@sap-ux/adp-tooling'; +import type { CFApp, Endpoint, SourceApplication } from '@sap-ux/adp-tooling'; +import type { AdaptationDescriptor } from '@sap-ux/axios-extension'; interface Choice { name: string; @@ -61,3 +62,38 @@ export const getAppRouterChoices = (isInternalUsage: boolean): { name: AppRouter } return options; }; + +/** + * Returns the choices for the adaptation prompt. + * + * @param {AdaptationDescriptor[]} adaptations - The adaptations to get the choices for. + * @returns {Array<{ name: string; value: AdaptationDescriptor }>} The choices for the adaptation prompt. + */ +export const getAdaptationChoices = ( + adaptations: AdaptationDescriptor[] +): Array<{ name: string; value: AdaptationDescriptor }> => { + return adaptations?.map((adaptation) => ({ + name: adaptation.title ? `${adaptation.title} (${adaptation.id})` : adaptation.id, + value: adaptation + })); +}; + +/** + * Returns the choices for the system prompt. + * + * @param {string[]} systems - The systems to get the choices for. + * @param {string} defaultSystem - The default system. + * @returns {Array<{ name: string; value: string }>} The choices for the system prompt. + */ +export const getKeyUserSystemChoices = ( + systems: Endpoint[], + defaultSystem: string +): Array<{ name: string; value: string }> => { + const endpointNames = getEndpointNames(systems); + return endpointNames.map((name) => { + return { + name: name === defaultSystem ? `${name} (Source system)` : name, + value: name + }; + }); +}; diff --git a/packages/generator-adp/src/app/questions/key-user.ts b/packages/generator-adp/src/app/questions/key-user.ts new file mode 100644 index 00000000000..1a868ac7224 --- /dev/null +++ b/packages/generator-adp/src/app/questions/key-user.ts @@ -0,0 +1,376 @@ +import type { + AbapServiceProvider, + AdaptationDescriptor, + FlexVersion, + KeyUserChangeContent +} from '@sap-ux/axios-extension'; +import type { ToolsLogger } from '@sap-ux/logger'; +import { isAxiosError } from '@sap-ux/axios-extension'; +import { validateEmptyString } from '@sap-ux/project-input-validator'; +import { type SystemLookup, getConfiguredProvider } from '@sap-ux/adp-tooling'; +import type { InputQuestion, ListQuestion, PasswordQuestion } from '@sap-ux/inquirer-common'; + +import type { + KeyUserImportPromptOptions, + KeyUserImportAnswers, + KeyUserImportQuestion, + KeyUserSystemPromptOptions, + KeyUserUsernamePromptOptions, + KeyUserPasswordPromptOptions, + KeyUserAdaptationPromptOptions +} from '../types'; +import { t } from '../../utils/i18n'; +import { keyUserPromptNames } from '../types'; +import { getAdaptationChoices, getKeyUserSystemChoices } from './helper/choices'; + +export const DEFAULT_ADAPTATION_ID = 'DEFAULT'; + +/** + * Determines the flex version to be used. If the first version is the draft (versionId "0"), use the second version (active version). + * + * @param {FlexVersion[]} flexVersions - The list of flex versions. + * @returns {string} The flex version to be used. + */ +export function determineFlexVersion(flexVersions: FlexVersion[]): string { + if (!flexVersions?.length) { + return ''; + } + if (flexVersions[0]?.versionId === '0') { + return flexVersions[1]?.versionId ?? ''; + } + return flexVersions[0]?.versionId ?? ''; +} + +/** + * Prompter class that guides the user through importing key-user changes. + */ +export class KeyUserImportPrompter { + /** + * Instance of AbapServiceProvider. + */ + private provider: AbapServiceProvider; + /** + * List of adaptations. + */ + private adaptations: AdaptationDescriptor[] = []; + /** + * List of key-user changes. + */ + private keyUserChanges: KeyUserChangeContent[] = []; + /** + * List of flex versions. + */ + private flexVersions: FlexVersion[] = []; + /** + * Indicates if authentication is required. + */ + private isAuthRequired: boolean = false; + + /** + * Constructs a new KeyUserImportPrompter. + * + * @param {SystemLookup} systemLookup - The system lookup. + * @param {string} componentId - The component ID. + * @param {AbapServiceProvider} defaultProvider - The default provider. + * @param {string} defaultSystem - The default system. + * @param {ToolsLogger} logger - The logger. + */ + constructor( + private readonly systemLookup: SystemLookup, + private readonly componentId: string, + private readonly defaultProvider: AbapServiceProvider, + private readonly defaultSystem: string, + private readonly logger: ToolsLogger + ) { + this.provider = defaultProvider; + } + + /** + * Returns the key-user changes. + * + * @returns {KeyUserChangeContent[]} The key-user changes. + */ + get changes(): KeyUserChangeContent[] { + return this.keyUserChanges; + } + + /** + * Builds the prompts for the key-user import page. + * + * @param {KeyUserImportPromptOptions} [promptOptions] - Per-prompt settings (hide/default). + * @returns {KeyUserImportQuestion[]} Questions for the key-user import page. + */ + getPrompts(promptOptions?: KeyUserImportPromptOptions): KeyUserImportQuestion[] { + const keyedPrompts: Record = { + [keyUserPromptNames.keyUserSystem]: this.getSystemPrompt(promptOptions?.[keyUserPromptNames.keyUserSystem]), + [keyUserPromptNames.keyUserUsername]: this.getUsernamePrompt( + promptOptions?.[keyUserPromptNames.keyUserUsername] + ), + [keyUserPromptNames.keyUserPassword]: this.getPasswordPrompt( + promptOptions?.[keyUserPromptNames.keyUserPassword] + ), + [keyUserPromptNames.keyUserAdaptation]: this.getAdaptationPrompt( + promptOptions?.[keyUserPromptNames.keyUserAdaptation] + ) + }; + + const questions: KeyUserImportQuestion[] = Object.entries(keyedPrompts) + .filter(([promptName, _]) => { + const options = promptOptions?.[promptName as keyUserPromptNames]; + return !(options && 'hide' in options && options.hide); + }) + .map(([_, question]) => question); + + return questions; + } + + /** + * Returns the system prompt. + * + * @param {KeyUserSystemPromptOptions} [options] - The options for the system prompt. + * @returns {KeyUserImportQuestion} The system prompt. + */ + private getSystemPrompt(options?: KeyUserSystemPromptOptions): KeyUserImportQuestion { + return { + type: 'list', + name: keyUserPromptNames.keyUserSystem, + message: t('prompts.systemLabel'), + choices: async () => { + const systems = await this.systemLookup.getSystems(); + return getKeyUserSystemChoices(systems, this.defaultSystem); + }, + guiOptions: { + mandatory: true, + breadcrumb: true + }, + default: options?.default ?? '', + validate: async (value: string, answers: KeyUserImportAnswers) => await this.validateSystem(value, answers) + } as ListQuestion; + } + + /** + * Returns the username prompt. + * + * @param {KeyUserUsernamePromptOptions} [options] - The options for the username prompt. + * @returns {KeyUserImportQuestion} The username prompt. + */ + private getUsernamePrompt(options?: KeyUserUsernamePromptOptions): KeyUserImportQuestion { + return { + type: 'input', + name: keyUserPromptNames.keyUserUsername, + message: t('prompts.usernameLabel'), + default: options?.default ?? '', + filter: (val: string): string => val.trim(), + guiOptions: { + mandatory: true + }, + when: (answers: KeyUserImportAnswers) => !!answers.keyUserSystem && this.isAuthRequired, + validate: (value: string) => validateEmptyString(value) + } as InputQuestion; + } + + /** + * Returns the password prompt. + * + * @param {KeyUserPasswordPromptOptions} [options] - The options for the password prompt. + * @returns {KeyUserImportQuestion} The password prompt. + */ + private getPasswordPrompt(options?: KeyUserPasswordPromptOptions): KeyUserImportQuestion { + return { + type: 'password', + name: keyUserPromptNames.keyUserPassword, + message: t('prompts.passwordLabel'), + mask: '*', + default: options?.default ?? '', + guiOptions: { + mandatory: true, + type: 'login' + }, + when: (answers: KeyUserImportAnswers) => !!answers.keyUserSystem && this.isAuthRequired, + validate: async (value: string, answers: KeyUserImportAnswers) => + await this.validatePassword(value, answers) + } as PasswordQuestion; + } + + /** + * Returns the adaptation prompt. + * + * @param {KeyUserAdaptationPromptOptions} [_] - The options for the adaptation prompt. + * @returns {KeyUserImportQuestion} The adaptation prompt. + */ + private getAdaptationPrompt(_?: KeyUserAdaptationPromptOptions): KeyUserImportQuestion { + return { + type: 'list', + name: keyUserPromptNames.keyUserAdaptation, + message: t('prompts.keyUserAdaptationLabel'), + guiOptions: { + mandatory: true, + breadcrumb: t('prompts.keyUserAdaptationBreadcrumb') + }, + choices: () => getAdaptationChoices(this.adaptations), + default: () => getAdaptationChoices(this.adaptations)[0]?.name, + validate: async (adaptation: AdaptationDescriptor) => await this.validateKeyUserChanges(adaptation?.id), + when: () => this.adaptations.length > 1 + } as ListQuestion; + } + + /** + * Loads adaptations for the current provider. + */ + private async loadAdaptations(): Promise { + const version = determineFlexVersion(this.flexVersions); + const lrep = this.provider?.getLayeredRepository(); + const response = await lrep?.listAdaptations(this.componentId, version); + this.adaptations = response?.adaptations ?? []; + this.logger.log(`Loaded adaptations: ${JSON.stringify(this.adaptations, null, 2)}`); + if (!this.adaptations.length) { + throw new Error(t('error.keyUserNoAdaptations')); + } + } + + /** + * Loads flex versions for the current provider. + */ + private async loadFlexVersions(): Promise { + const lrep = this.provider?.getLayeredRepository(); + const response = await lrep?.getFlexVersions(this.componentId); + this.flexVersions = response?.versions ?? []; + this.logger.log(`Loaded flex versions: ${JSON.stringify(this.flexVersions, null, 2)}`); + } + + /** + * Resets the state by clearing adaptations, flex versions, and key-user changes. + */ + private resetState(): void { + this.flexVersions = []; + this.adaptations = []; + this.keyUserChanges = []; + } + + /** + * Loads flex versions and adaptations, then validates key-user changes if only DEFAULT adaptation exists. + * + * @returns The result of key-user validation if only DEFAULT exists, or true. + */ + private async loadDataAndValidateKeyUserChanges(): Promise { + /** + * Ensure provider is authenticated for CloudReady systems before making API calls + */ + await this.provider.isAbapCloud(); + + await this.loadFlexVersions(); + await this.loadAdaptations(); + + if (this.adaptations.length === 1 && this.adaptations[0]?.id === DEFAULT_ADAPTATION_ID) { + return await this.validateKeyUserChanges(DEFAULT_ADAPTATION_ID); + } + return true; + } + + /** + * Validates the system selection by testing the connection. + * + * @param {string} system - The selected system. + * @param {KeyUserImportAnswers} answers - The configuration answers provided by the user. + * @returns An error message if validation fails, or true if the system selection is valid. + */ + private async validateSystem(system: string, answers: KeyUserImportAnswers): Promise { + const validationResult = validateEmptyString(system); + if (typeof validationResult === 'string') { + return validationResult; + } + + try { + this.resetState(); + if (system === this.defaultSystem && this.defaultProvider) { + this.provider = this.defaultProvider; + this.isAuthRequired = false; + return await this.loadDataAndValidateKeyUserChanges(); + } + + this.isAuthRequired = await this.systemLookup.getSystemRequiresAuth(system); + if (!this.isAuthRequired) { + const options = { + system, + client: undefined, + username: answers.keyUserUsername, + password: answers.keyUserPassword + }; + this.provider = await getConfiguredProvider(options, this.logger); + return await this.loadDataAndValidateKeyUserChanges(); + } + } catch (e) { + return e.message; + } + + return true; + } + + /** + * Validates the password by testing the connection. + * + * @param {string} password - The inputted password. + * @param {KeyUserImportAnswers} answers - The configuration answers provided by the user. + * @returns An error message if validation fails, or true if the password is valid. + */ + private async validatePassword(password: string, answers: KeyUserImportAnswers): Promise { + const validationResult = validateEmptyString(password); + if (typeof validationResult === 'string') { + return validationResult; + } + + try { + this.resetState(); + const options = { + system: answers.keyUserSystem, + client: undefined, + username: answers.keyUserUsername, + password + }; + this.provider = await getConfiguredProvider(options, this.logger); + return await this.loadDataAndValidateKeyUserChanges(); + } catch (e) { + return e.message; + } + } + + /** + * Validates the key-user changes by testing the connection. + * + * @param {string} adaptationId - The selected adaptation. + * @returns An error message if validation fails, or true if the key-user changes are valid. + */ + private async validateKeyUserChanges(adaptationId: string | undefined): Promise { + try { + if (!this.provider || !adaptationId) { + return false; + } + + const lrep = this.provider?.getLayeredRepository(); + const data = await lrep?.getKeyUserData(this.componentId, adaptationId); + this.keyUserChanges = data?.contents ?? []; + this.logger.debug( + `Retrieved ${this.keyUserChanges.length} key-user change(s) for adaptation ${adaptationId}` + ); + + if (!this.keyUserChanges.length) { + if (adaptationId === DEFAULT_ADAPTATION_ID && this.adaptations.length === 1) { + return t('error.keyUserNoChangesDefault'); + } + this.logger.warn(`No key-user changes found for adaptation: ${adaptationId}`); + return t('error.keyUserNoChangesAdaptation', { adaptationId }); + } + + return true; + } catch (e) { + this.logger.error(`Error validating key-user changes for adaptation ${adaptationId}: ${e.message}`); + this.logger.debug(e); + + if (isAxiosError(e) && (e.response?.status === 405 || e.response?.status === 404)) { + return t('error.keyUserNotSupported'); + } + + return e.message; + } + } +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index a2b1e054768..f4b1b1e01fe 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -3,6 +3,7 @@ import type { AppWizard } from '@sap-devx/yeoman-ui-types'; import type { YUIQuestion } from '@sap-ux/inquirer-common'; import type { TelemetryData } from '@sap-ux/fiori-generator-shared'; +import type { AdaptationDescriptor } from '@sap-ux/axios-extension'; import type { AttributesAnswers, ConfigAnswers } from '@sap-ux/adp-tooling'; export interface AdpGeneratorOptions extends Generator.GeneratorOptions { @@ -110,7 +111,8 @@ export enum attributePromptNames { ui5ValidationCli = 'ui5ValidationCli', enableTypeScript = 'enableTypeScript', addDeployConfig = 'addDeployConfig', - addFlpConfig = 'addFlpConfig' + addFlpConfig = 'addFlpConfig', + importKeyUserChanges = 'importKeyUserChanges' } export type AttributesQuestion = YUIQuestion; @@ -152,6 +154,11 @@ export interface AddFlpConfigPromptOptions { hasBaseAppInbounds?: boolean; } +export interface ImportKeyUserChangesPromptOptions { + hide?: boolean; + default?: boolean; +} + export type AttributePromptOptions = Partial<{ [attributePromptNames.projectName]: ProjectNamePromptOptions; [attributePromptNames.title]: ApplicationTitlePromptOptions; @@ -162,8 +169,58 @@ export type AttributePromptOptions = Partial<{ [attributePromptNames.enableTypeScript]: EnableTypeScriptPromptOptions; [attributePromptNames.addDeployConfig]: AddDeployConfigPromptOptions; [attributePromptNames.addFlpConfig]: AddFlpConfigPromptOptions; + [attributePromptNames.importKeyUserChanges]: ImportKeyUserChangesPromptOptions; +}>; + +export type KeyUserImportQuestion = YUIQuestion; + +/** + * Enumeration of prompt names used in the key-user import. + */ +export enum keyUserPromptNames { + keyUserSystem = 'keyUserSystem', + keyUserUsername = 'keyUserUsername', + keyUserPassword = 'keyUserPassword', + keyUserAdaptation = 'keyUserAdaptation' +} + +export interface KeyUserSystemPromptOptions { + default?: string; + hide?: boolean; +} + +export interface KeyUserUsernamePromptOptions { + default?: string; + hide?: boolean; +} + +export interface KeyUserPasswordPromptOptions { + default?: string; + hide?: boolean; +} + +export interface KeyUserAdaptationPromptOptions { + default?: string; + hide?: boolean; +} + +/** + * Options for the key-user import inquirer & the prompts. + */ +export type KeyUserImportPromptOptions = Partial<{ + [keyUserPromptNames.keyUserSystem]: KeyUserSystemPromptOptions; + [keyUserPromptNames.keyUserUsername]: KeyUserUsernamePromptOptions; + [keyUserPromptNames.keyUserPassword]: KeyUserPasswordPromptOptions; + [keyUserPromptNames.keyUserAdaptation]: KeyUserAdaptationPromptOptions; }>; +export interface KeyUserImportAnswers { + keyUserSystem: string; + keyUserUsername?: string; + keyUserPassword?: string; + keyUserAdaptation: AdaptationDescriptor; +} + export enum targetEnvPromptNames { targetEnv = 'targetEnv' } diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 3b9f31c1448..1077a148828 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -4,6 +4,8 @@ "configurationDescr": "Configure the system and select an application.", "projectAttributesName": "Project Attributes", "projectAttributesDescr": "Configure the main project attributes.", + "keyUserImportName": "Import Key User Changes", + "keyUserImportDescr": "You can either continue with the pre-selected source system or choose a system from which the key-user changes are taken over.", "flpConfigName": "SAP Fiori Launchpad Configuration: Tile Settings", "tileSettingsName": "SAP Fiori Launchpad Configuration: Tile Handling", "tileSettingsDescr": "Add a new tile or replace existing tiles of the base application.\nProject: {{projectName}}", @@ -48,6 +50,9 @@ "ui5VersionTooltip": "Select the SAPUI5 version you want to use to preview your app variant.", "addDeployConfig": "Add Deployment Configuration", "addFlpConfig": "Add SAP Fiori Launchpad Configuration", + "importKeyUserChangesLabel": "Import Key User Changes", + "keyUserAdaptationLabel": "Context-Based Adaptation", + "keyUserAdaptationBreadcrumb": "Adaptation", "targetEnvLabel": "Environment", "targetEnvTooltip": "Select the target environment for your Adaptation Project.", "targetEnvBreadcrumb": "Target Environment", @@ -104,9 +109,13 @@ "projectDoesNotExist": "The project does not exist. Please select an MTA project.", "projectDoesNotExistMta": "Provide the path to the MTA project where you want to have your Adaptation Project created.", "noAdaptableBusinessServiceFoundInMta": "No adaptable business service found in the MTA.", - "fetchBaseInboundsFailed": "Fetching base application inbounds failed: {{error}}." + "fetchBaseInboundsFailed": "Fetching base application inbounds failed: {{error}}.", + "keyUserNoChangesDefault": "Only a single adaptation ('DEFAULT') was found and no key-user changes were found for it. Please select a different system to continue, or navigate back to the 'Project Attributes' page and choose not to import key-user changes.", + "keyUserNoChangesAdaptation": "No key-user changes have been found for the '{{adaptationId}}' adaptation. Please select a different adaptation.", + "keyUserNoAdaptations": "No context-based adaptations have been found for the selected application. Please refer to the documentation.", + "keyUserNotSupported": "The selected system does not allow or support the import of key-user changes. Please refer to the documentation." }, "validators": { "ui5VersionNotDetectedError": "The SAPUI5 version of the selected system cannot be determined. You are able to create and edit adaptation projects that use the latest SAPUI5 version, but they are not usable on this system until the system's SAPUI5 version is upgraded to version 1.71 or higher." } -} +} \ No newline at end of file diff --git a/packages/generator-adp/src/utils/steps.ts b/packages/generator-adp/src/utils/steps.ts index ad3b6d21319..cd6c7a1d7b6 100644 --- a/packages/generator-adp/src/utils/steps.ts +++ b/packages/generator-adp/src/utils/steps.ts @@ -99,6 +99,15 @@ export function getDeployPage(): IPrompt { return { name: t('yuiNavSteps.deployConfigName'), description: t('yuiNavSteps.deployConfigDescr') }; } +/** + * Returns the key user import page step. + * + * @returns {IPrompt} The key user import wizard page. + */ +export function getKeyUserImportPage(): IPrompt { + return { name: t('yuiNavSteps.keyUserImportName'), description: t('yuiNavSteps.keyUserImportDescr') }; +} + /** * Dynamically adds or removes a step in the Yeoman UI wizard. * diff --git a/packages/generator-adp/test/__snapshots__/app.test.ts.snap b/packages/generator-adp/test/__snapshots__/app.test.ts.snap index f7bc5397a46..9ffc2eab541 100644 --- a/packages/generator-adp/test/__snapshots__/app.test.ts.snap +++ b/packages/generator-adp/test/__snapshots__/app.test.ts.snap @@ -79,6 +79,27 @@ server: " `; +exports[`Adaptation Project Generator Integration Test ABAP Environment should generate adaptation project with key user changes 1`] = ` +{ + "changeType": "renameLabel", + "content": { + "annotationPath": "/category_ID@com.vocabularies.Common.v1.Label", + }, + "fileName": "id_1767885281745_1726_renameLabel", + "fileType": "annotation_change", + "layer": "CUSTOMER_BASE", + "namespace": "apps/customer.app.variant/changes/", + "projectId": "customer.app.variant", + "reference": "customer.app.variant", + "selector": { + "serviceUrl": "/odata/test/service", + }, + "support": { + "generator": "adp-key-user-converter", + }, +} +`; + exports[`Adaptation Project Generator Integration Test ABAP Environment should generate an onPremise adaptation project successfully 1`] = ` "{ "fileName": "manifest", diff --git a/packages/generator-adp/test/app.test.ts b/packages/generator-adp/test/app.test.ts index 3fb9754c400..fbac2c47dd1 100644 --- a/packages/generator-adp/test/app.test.ts +++ b/packages/generator-adp/test/app.test.ts @@ -34,7 +34,11 @@ import { loadCfConfig, validateUI5VersionExists } from '@sap-ux/adp-tooling'; -import { type AbapServiceProvider, AdaptationProjectType } from '@sap-ux/axios-extension'; +import { + type AbapServiceProvider, + AdaptationProjectType, + type LayeredRepositoryService +} from '@sap-ux/axios-extension'; import { isAppStudio } from '@sap-ux/btp-utils'; import { isInternalFeaturesSettingEnabled, isFeatureEnabled } from '@sap-ux/feature-toggle'; import { isCli, isExtensionInstalled, sendTelemetry } from '@sap-ux/fiori-generator-shared'; @@ -46,6 +50,7 @@ import { getCredentialsFromStore } from '@sap-ux/system-access'; import type { AdpGeneratorOptions } from '../src/app'; import adpGenerator from '../src/app'; import { ConfigPrompter } from '../src/app/questions/configuration'; +import { KeyUserImportPrompter } from '../src/app/questions/key-user'; import { getDefaultProjectName } from '../src/app/questions/helper/default-values'; import { TargetEnv, type JsonInput, type TargetEnvAnswers } from '../src/app/types'; import { EventName } from '../src/telemetry'; @@ -550,6 +555,69 @@ describe('Adaptation Project Generator Integration Test', () => { ); }); + it('should generate adaptation project with key user changes', async () => { + mockIsAppStudio.mockReturnValue(false); + + const mockAdaptations = [{ id: 'DEFAULT', title: '', type: 'DEFAULT' }]; + const mockKeyUserChange = { + content: { + fileName: 'id_1767885281745_1726_renameLabel', + changeType: 'renameLabel', + reference: apps[0].id, + layer: 'CUSTOMER', + namespace: `apps/${apps[0].id}/changes/`, + projectId: apps[0].id, + fileType: 'annotation_change', + content: { + annotationPath: '/category_ID@com.vocabularies.Common.v1.Label' + }, + selector: { + serviceUrl: '/odata/test/service' + } + }, + texts: { + annotationText: { + value: 'Category ID', + type: 'XFLD' + } + } + }; + + jest.spyOn(KeyUserImportPrompter.prototype, 'changes', 'get').mockReturnValue([mockKeyUserChange]); + + const keyUserAnswers = { + ...answers, + importKeyUserChanges: true, + keyUserSystem: 'urlA', + keyUserAdaptation: mockAdaptations[0] + }; + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts(keyUserAnswers); + + await expect(runContext.run()).resolves.not.toThrow(); + + const generatedDirs = fs.readdirSync(testOutputDir); + expect(generatedDirs).toContain(answers.projectName); + const projectFolder = join(testOutputDir, answers.projectName); + + // Verify key user change file was written + const changesDir = join(projectFolder, 'webapp', 'changes'); + expect(fs.existsSync(changesDir)).toBe(true); + + const changeFiles = fs.readdirSync(changesDir).filter((file) => file.endsWith('.change')); + expect(changeFiles.length).toBeGreaterThan(0); + + // Verify the change file content + const changeFilePath = join(changesDir, changeFiles[0]); + expect(fs.existsSync(changeFilePath)).toBe(true); + + const changeContent = JSON.parse(fs.readFileSync(changeFilePath, 'utf8')); + expect(changeContent).toMatchSnapshot(); + }); + it('should create adaptation project from json correctly', async () => { // NOTE: This test uses .withArguments() which bypasses the normal yeoman prompting lifecycle and goes directly to the writing phase. // This can cause race conditions with other tests that use the same output directory, as the generator doesn't go through the standard prompting -> writing flow. diff --git a/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts b/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts index af0c0b93857..551ee87da23 100644 --- a/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/additional-messages.test.ts @@ -9,9 +9,13 @@ import { getVersionAdditionalMessages, getTargetEnvAdditionalMessages } from '../../../../src/app/questions/helper/additional-messages'; -import { t } from '../../../../src/utils/i18n'; +import { initI18n, t } from '../../../../src/utils/i18n'; describe('additional-messages', () => { + beforeAll(async () => { + await initI18n(); + }); + describe('getSystemAdditionalMessages', () => { it('should return undefined if flexUISystem is undefined', () => { const result = getSystemAdditionalMessages(undefined, false); diff --git a/packages/generator-adp/test/unit/questions/helper/choices.test.ts b/packages/generator-adp/test/unit/questions/helper/choices.test.ts index 3595c221d0d..e06efc93f59 100644 --- a/packages/generator-adp/test/unit/questions/helper/choices.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/choices.test.ts @@ -1,10 +1,13 @@ import { AppRouterType } from '@sap-ux/adp-tooling'; -import type { CFApp, SourceApplication } from '@sap-ux/adp-tooling'; +import type { AdaptationDescriptor } from '@sap-ux/axios-extension'; +import type { CFApp, Endpoint, SourceApplication } from '@sap-ux/adp-tooling'; import { getApplicationChoices, getCFAppChoices, - getAppRouterChoices + getAppRouterChoices, + getAdaptationChoices, + getKeyUserSystemChoices } from '../../../../src/app/questions/helper/choices'; describe('Choices Helper Functions', () => { @@ -154,4 +157,79 @@ describe('Choices Helper Functions', () => { }); }); }); + + describe('getAdaptationChoices', () => { + const mockAdaptation1: AdaptationDescriptor = { + id: 'DEFAULT', + title: '', + type: 'DEFAULT' + }; + + const mockAdaptation2: AdaptationDescriptor = { + id: 'CTX1', + title: 'Context 1', + type: 'CONTEXT', + contexts: { role: ['/UI2/ADMIN'] } + }; + + const mockAdaptation3: AdaptationDescriptor = { + id: 'CTX2', + type: 'CONTEXT' + }; + + test('should handle multiple adaptations', () => { + const adaptations = [mockAdaptation1, mockAdaptation2, mockAdaptation3]; + const result = getAdaptationChoices(adaptations); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + value: mockAdaptation1, + name: 'DEFAULT' + }); + expect(result[1]).toEqual({ + value: mockAdaptation2, + name: 'Context 1 (CTX1)' + }); + expect(result[2]).toEqual({ + value: mockAdaptation3, + name: 'CTX2' + }); + }); + + test('should handle undefined/null adaptations', () => { + const result = getAdaptationChoices(undefined as any); + expect(result).toBeUndefined(); + }); + }); + + describe('getKeyUserSystemChoices', () => { + const mockSystems: Endpoint[] = [ + { Name: 'SystemA', Client: '100', Url: '/systema', Authentication: 'NoAuthentication' }, + { Name: 'SystemB', Client: '200', Url: '/systemb', Authentication: 'Basic' }, + { Name: 'SystemC', Client: '300', Url: '/systemc', Authentication: 'Basic' } + ]; + + test('should create choices with default system marked as "Source system"', () => { + const result = getKeyUserSystemChoices(mockSystems, 'SystemA'); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + name: 'SystemA (Source system)', + value: 'SystemA' + }); + expect(result[1]).toEqual({ + name: 'SystemB', + value: 'SystemB' + }); + expect(result[2]).toEqual({ + name: 'SystemC', + value: 'SystemC' + }); + }); + + test('should handle empty array', () => { + const result = getKeyUserSystemChoices([], 'SystemA'); + expect(result).toHaveLength(0); + }); + }); }); diff --git a/packages/generator-adp/test/unit/questions/key-user.test.ts b/packages/generator-adp/test/unit/questions/key-user.test.ts new file mode 100644 index 00000000000..25fa53888d5 --- /dev/null +++ b/packages/generator-adp/test/unit/questions/key-user.test.ts @@ -0,0 +1,576 @@ +import type { + AbapServiceProvider, + AdaptationDescriptor, + AxiosError, + FlexVersion, + KeyUserChangeContent +} from '@sap-ux/axios-extension'; +import { isAxiosError } from '@sap-ux/axios-extension'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { SystemLookup } from '@sap-ux/adp-tooling'; +import { getConfiguredProvider } from '@sap-ux/adp-tooling'; +import { validateEmptyString } from '@sap-ux/project-input-validator'; + +import { + KeyUserImportPrompter, + DEFAULT_ADAPTATION_ID, + determineFlexVersion +} from '../../../src/app/questions/key-user'; +import { initI18n, t } from '../../../src/utils/i18n'; +import { keyUserPromptNames } from '../../../src/app/types'; +import { getAdaptationChoices, getKeyUserSystemChoices } from '../../../src/app/questions/helper/choices'; + +jest.mock('@sap-ux/project-input-validator', () => ({ + ...jest.requireActual('@sap-ux/project-input-validator'), + validateEmptyString: jest.fn() +})); + +jest.mock('@sap-ux/adp-tooling', () => ({ + ...jest.requireActual('@sap-ux/adp-tooling'), + getConfiguredProvider: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/additional-messages', () => ({ + getKeyUserSystemAdditionalMessages: jest.fn() +})); + +jest.mock('../../../src/app/questions/helper/choices', () => ({ + getAdaptationChoices: jest.fn(), + getKeyUserSystemChoices: jest.fn() +})); + +jest.mock('@sap-ux/axios-extension', () => ({ + ...jest.requireActual('@sap-ux/axios-extension'), + isAxiosError: jest.fn() +})); + +const logger: ToolsLogger = { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + log: jest.fn() +} as unknown as ToolsLogger; + +const mockLayeredRepository = { + listAdaptations: jest.fn(), + getKeyUserData: jest.fn(), + getFlexVersions: jest.fn() +}; + +const defaultProvider: AbapServiceProvider = { + getLayeredRepository: jest.fn().mockReturnValue(mockLayeredRepository), + isAbapCloud: jest.fn().mockResolvedValue(true) +} as unknown as AbapServiceProvider; + +const systemLookup: SystemLookup = { + getSystems: jest.fn().mockResolvedValue([ + { Name: 'SystemA', Client: '100', Url: '/systema', Authentication: 'NoAuthentication' }, + { Name: 'SystemB', Client: '200', Url: '/systemb', Authentication: 'Basic' } + ]), + getSystemRequiresAuth: jest.fn() +} as unknown as SystemLookup; + +const mockAdaptations: AdaptationDescriptor[] = [ + { id: DEFAULT_ADAPTATION_ID, title: 'Default Adaptation', type: 'DEFAULT' } +]; + +const mockMultipleAdaptations: AdaptationDescriptor[] = [ + { id: DEFAULT_ADAPTATION_ID, title: 'Default Adaptation', type: 'DEFAULT' }, + { id: 'CTX1', title: 'Context 1', type: 'CONTEXT', contexts: { role: ['/UI2/ADMIN'] } } +]; + +const mockFlexVersions: FlexVersion[] = [{ versionId: '1.0.0' } as FlexVersion]; + +const mockKeyUserChanges: KeyUserChangeContent[] = [ + { + content: { + fileName: 'test.change', + changeType: 'rename', + reference: 'test.app' + } + } +]; + +const getSystemsMock = systemLookup.getSystems as jest.Mock; +const isAxiosErrorMock = isAxiosError as unknown as jest.Mock; +const validateEmptyStringMock = validateEmptyString as jest.Mock; +const getConfiguredProviderMock = getConfiguredProvider as jest.Mock; +const getAdaptationChoicesMock = getAdaptationChoices as jest.Mock; +const getKeyUserSystemChoicesMock = getKeyUserSystemChoices as jest.Mock; +const getKeyUserDataMock = mockLayeredRepository.getKeyUserData as jest.Mock; +const getFlexVersionsMock = mockLayeredRepository.getFlexVersions as jest.Mock; +const listAdaptationsMock = mockLayeredRepository.listAdaptations as jest.Mock; +const getSystemRequiresAuthMock = systemLookup.getSystemRequiresAuth as jest.Mock; + +describe('KeyUserImportPrompter', () => { + const componentId = 'demoapps.rta'; + const defaultSystem = 'SystemA'; + let prompter: KeyUserImportPrompter; + + beforeAll(async () => { + await initI18n(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + validateEmptyStringMock.mockReturnValue(true); + getAdaptationChoicesMock.mockReturnValue([{ name: 'Default Adaptation', value: mockAdaptations[0] }]); + getKeyUserSystemChoicesMock.mockReturnValue([ + { name: 'SystemA', value: 'SystemA' }, + { name: 'SystemB', value: 'SystemB' } + ]); + + prompter = new KeyUserImportPrompter(systemLookup, componentId, defaultProvider, defaultSystem, logger); + }); + + describe('Constructor and Properties', () => { + it('should initialize with provided parameters', () => { + expect(prompter).toBeDefined(); + expect(prompter.changes).toEqual([]); + }); + }); + + describe('getPrompts', () => { + it('should return all prompts by default', () => { + const prompts = prompter.getPrompts(); + expect(prompts).toHaveLength(4); + expect(prompts.map((p) => p.name)).toEqual([ + keyUserPromptNames.keyUserSystem, + keyUserPromptNames.keyUserUsername, + keyUserPromptNames.keyUserPassword, + keyUserPromptNames.keyUserAdaptation + ]); + }); + + it('should filter out hidden prompts', () => { + const prompts = prompter.getPrompts({ + [keyUserPromptNames.keyUserSystem]: { hide: true }, + [keyUserPromptNames.keyUserPassword]: { hide: true } + }); + expect(prompts).toHaveLength(2); + expect(prompts.map((p) => p.name)).toEqual([ + keyUserPromptNames.keyUserUsername, + keyUserPromptNames.keyUserAdaptation + ]); + }); + }); + + describe('System Prompt', () => { + describe('choices', () => { + it('should call getKeyUserSystemChoices with systems and default system', async () => { + const mockSystems = [ + { Name: 'SystemA', Client: '100', Url: '/systema', Authentication: 'NoAuthentication' }, + { Name: 'SystemB', Client: '200', Url: '/systemb', Authentication: 'Basic' } + ]; + getSystemsMock.mockResolvedValue(mockSystems); + + const prompt = (prompter as any).getSystemPrompt(); + await prompt.choices(); + + expect(getSystemsMock).toHaveBeenCalled(); + expect(getKeyUserSystemChoicesMock).toHaveBeenCalledWith(mockSystems, defaultSystem); + }); + }); + + describe('default', () => { + it('should use provided default value', () => { + const prompt = (prompter as any).getSystemPrompt({ default: 'SystemB' }); + expect(prompt.default).toBe('SystemB'); + }); + }); + + describe('validate', () => { + const answers = { + keyUserSystem: 'SystemA', + keyUserUsername: 'user', + keyUserPassword: 'pass', + keyUserAdaptation: mockAdaptations[0] + }; + + it('should return error if system is empty', async () => { + validateEmptyStringMock.mockReturnValue('System is required'); + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.('', answers); + expect(result).toBe('System is required'); + }); + + it('should use default provider when default system is selected', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.(defaultSystem, answers); + + expect(result).toBe(true); + expect(getSystemRequiresAuthMock).not.toHaveBeenCalled(); + expect(getConfiguredProviderMock).not.toHaveBeenCalled(); + expect(prompter.changes).toEqual(mockKeyUserChanges); + }); + + it('should check auth requirement for non-default system', async () => { + getSystemRequiresAuthMock.mockResolvedValue(false); + getConfiguredProviderMock.mockResolvedValue(defaultProvider); + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.('SystemB', answers); + + expect(result).toBe(true); + expect(getSystemRequiresAuthMock).toHaveBeenCalledWith('SystemB'); + expect(getConfiguredProviderMock).toHaveBeenCalled(); + }); + + it('should return true if auth is required (will show password prompt)', async () => { + getSystemRequiresAuthMock.mockResolvedValue(true); + + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.('SystemB', answers); + + expect(result).toBe(true); + expect(getSystemRequiresAuthMock).toHaveBeenCalledWith('SystemB'); + expect(getConfiguredProviderMock).not.toHaveBeenCalled(); + }); + + it('should return error when no key-user changes found for DEFAULT', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: [] }); + + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.(defaultSystem, answers); + + expect(result).toBe(t('error.keyUserNoChangesDefault')); + }); + + it('should return error message on exception', async () => { + getSystemRequiresAuthMock.mockRejectedValue(new Error('Connection failed')); + + const prompt = prompter['getSystemPrompt'](); + const result = await prompt?.validate?.('SystemB', answers); + + expect(result).toBe('Connection failed'); + }); + }); + }); + + describe('Username Prompt', () => { + describe('default', () => { + it('should use provided default value', () => { + const prompt = prompter['getUsernamePrompt']({ default: 'testuser' }); + expect(prompt.default).toBe('testuser'); + }); + }); + + describe('filter', () => { + it('should trim whitespace from input', () => { + const prompt = prompter['getUsernamePrompt'](); + expect( + prompt?.filter?.(' testuser ', { + keyUserSystem: 'SystemB', + keyUserAdaptation: mockAdaptations[0] + }) + ).toBe('testuser'); + }); + }); + + describe('validate', () => { + it('should call validateEmptyString', () => { + validateEmptyStringMock.mockReturnValue('Username is required'); + const prompt = prompter['getUsernamePrompt'](); + const result = prompt?.validate?.('', { + keyUserSystem: 'SystemB', + keyUserAdaptation: mockAdaptations[0] + }); + expect(result).toBe('Username is required'); + expect(validateEmptyStringMock).toHaveBeenCalledWith(''); + }); + }); + + describe('when', () => { + it('should return true when auth is required and system is selected', () => { + prompter['isAuthRequired'] = true; + const prompt = (prompter as any).getUsernamePrompt(); + expect(prompt.when({ keyUserSystem: 'SystemB' })).toBe(true); + }); + + it('should return false when auth is not required', () => { + prompter['isAuthRequired'] = false; + const prompt = (prompter as any).getUsernamePrompt(); + expect(prompt.when({ keyUserSystem: 'SystemB' })).toBe(false); + }); + + it('should return false when system is not selected', () => { + prompter['isAuthRequired'] = true; + const prompt = (prompter as any).getUsernamePrompt(); + expect(prompt.when({})).toBe(false); + }); + }); + }); + + describe('Password Prompt', () => { + describe('default', () => { + it('should use provided default value', () => { + const prompt = prompter['getPasswordPrompt']({ + default: 'testpass' + }); + expect(prompt.default).toBe('testpass'); + }); + }); + + describe('validate', () => { + const answers = { + keyUserSystem: 'SystemB', + keyUserUsername: 'user', + keyUserPassword: 'pass', + keyUserAdaptation: mockAdaptations[0] + }; + + it('should return error if password is empty', async () => { + validateEmptyStringMock.mockReturnValue('Password is required'); + const prompt = prompter['getPasswordPrompt'](); + const result = await prompt?.validate?.('', answers); + expect(result).toBe('Password is required'); + }); + + it('should create provider and validate key-user changes', async () => { + getConfiguredProviderMock.mockResolvedValue(defaultProvider); + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const prompt = prompter['getPasswordPrompt'](); + const result = await prompt?.validate?.('password123', answers); + + expect(result).toBe(true); + expect(getConfiguredProviderMock).toHaveBeenCalledWith( + { + system: 'SystemB', + client: undefined, + username: 'user', + password: 'password123' + }, + logger + ); + expect(getKeyUserDataMock).toHaveBeenCalledWith(componentId, DEFAULT_ADAPTATION_ID); + }); + + it('should return error message on exception', async () => { + getConfiguredProviderMock.mockRejectedValue(new Error('Authentication failed')); + + const prompt = prompter['getPasswordPrompt'](); + const result = await prompt?.validate?.('password123', answers); + + expect(result).toBe('Authentication failed'); + }); + }); + + describe('when', () => { + it('should return true when auth is required and system is selected', () => { + prompter['isAuthRequired'] = true; + const prompt = (prompter as any).getPasswordPrompt(); + expect(prompt.when({ keyUserSystem: 'SystemB' })).toBe(true); + }); + + it('should return false when auth is not required', () => { + prompter['isAuthRequired'] = false; + const prompt = (prompter as any).getPasswordPrompt(); + expect(prompt.when({ keyUserSystem: 'SystemB' })).toBe(false); + }); + + it('should return false when system is not selected', () => { + prompter['isAuthRequired'] = true; + const prompt = (prompter as any).getPasswordPrompt(); + expect(prompt.when({})).toBe(false); + }); + }); + }); + + describe('Adaptation Prompt', () => { + describe('choices', () => { + it('should call getAdaptationChoices with adaptations', () => { + prompter['adaptations'] = mockAdaptations; + const prompt = (prompter as any).getAdaptationPrompt(); + prompt.choices(); + + expect(getAdaptationChoicesMock).toHaveBeenCalledWith(mockAdaptations); + }); + }); + + describe('default', () => { + it('should return first choice name', () => { + prompter['adaptations'] = mockAdaptations; + const prompt = prompter['getAdaptationPrompt'](); + const result = prompt?.default?.(); + expect(result).toBe('Default Adaptation'); + }); + }); + + describe('validate', () => { + it('should return false if adaptation is null', async () => { + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(null); + expect(result).toBe(false); + }); + + it('should return true when key-user changes are found', async () => { + prompter['adaptations'] = mockAdaptations; + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(mockAdaptations[0]); + + expect(result).toBe(true); + expect(getKeyUserDataMock).toHaveBeenCalledWith(componentId, DEFAULT_ADAPTATION_ID); + expect(prompter.changes).toEqual(mockKeyUserChanges); + }); + + it('should return error when no changes found for DEFAULT adaptation (single adaptation)', async () => { + prompter['adaptations'] = mockAdaptations; + getKeyUserDataMock.mockResolvedValue({ contents: [] }); + + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(mockAdaptations[0]); + + expect(result).toBe(t('error.keyUserNoChangesDefault')); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should return error when no changes found for specific adaptation', async () => { + prompter['adaptations'] = mockMultipleAdaptations; + getKeyUserDataMock.mockResolvedValue({ contents: [] }); + + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(mockMultipleAdaptations[1]); + + expect(result).toBe(t('error.keyUserNoChangesAdaptation', { adaptationId: 'CTX1' })); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should return error message on exception', async () => { + prompter['adaptations'] = mockAdaptations; + const error = new Error('API call failed'); + getKeyUserDataMock.mockRejectedValue(error); + isAxiosErrorMock.mockReturnValue(false); + + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(mockAdaptations[0]); + + expect(result).toBe('API call failed'); + expect(logger.error).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith(error); + }); + + it('should return user-friendly message for 404 status code', async () => { + prompter['adaptations'] = mockAdaptations; + const axiosError = { + isAxiosError: true, + message: 'Not Found', + name: 'AxiosError', + response: { + status: 404, + statusText: 'Not Found' + } + } as AxiosError; + getKeyUserDataMock.mockRejectedValue(axiosError); + isAxiosErrorMock.mockReturnValue(true); + + const prompt = prompter['getAdaptationPrompt'](); + const result = await prompt?.validate?.(mockAdaptations[0]); + + expect(result).toBe(t('error.keyUserNotSupported')); + expect(logger.error).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith(axiosError); + }); + }); + + describe('when', () => { + it('should return true when multiple adaptations exist', () => { + prompter['adaptations'] = mockMultipleAdaptations; + const prompt = (prompter as any).getAdaptationPrompt(); + expect(prompt.when()).toBe(true); + }); + + it('should return false when only one adaptation exists', () => { + prompter['adaptations'] = mockAdaptations; + const prompt = (prompter as any).getAdaptationPrompt(); + expect(prompt.when()).toBe(false); + }); + }); + }); + + describe('Internal Methods', () => { + describe('loadDataAndValidateKeyUserChanges', () => { + it('should load flex versions and adaptations', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const result = await prompter['loadDataAndValidateKeyUserChanges'](); + + expect(getFlexVersionsMock).toHaveBeenCalledWith(componentId); + expect(listAdaptationsMock).toHaveBeenCalledWith(componentId, '1.0.0'); + expect(result).toBe(true); + }); + + it('should validate key-user changes when only DEFAULT adaptation exists', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockAdaptations }); + getKeyUserDataMock.mockResolvedValue({ contents: mockKeyUserChanges }); + + const result = await prompter['loadDataAndValidateKeyUserChanges'](); + + expect(getKeyUserDataMock).toHaveBeenCalledWith(componentId, DEFAULT_ADAPTATION_ID); + expect(result).toBe(true); + }); + + it('should return true when multiple adaptations exist', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: mockMultipleAdaptations }); + + const result = await prompter['loadDataAndValidateKeyUserChanges'](); + + expect(result).toBe(true); + expect(getKeyUserDataMock).not.toHaveBeenCalled(); + }); + + it('should throw error when no adaptations found', async () => { + getFlexVersionsMock.mockResolvedValue({ versions: mockFlexVersions }); + listAdaptationsMock.mockResolvedValue({ adaptations: [] }); + + await expect(prompter['loadDataAndValidateKeyUserChanges']()).rejects.toThrow(); + }); + }); + }); +}); + +describe('determineFlexVersion', () => { + it('should return second version when first version is draft (versionId "0")', () => { + const flexVersions: FlexVersion[] = [ + { versionId: '0' } as FlexVersion, + { versionId: '00025E29EA041FD0BB9495569AC3D2AD' } as FlexVersion + ]; + expect(determineFlexVersion(flexVersions)).toBe('00025E29EA041FD0BB9495569AC3D2AD'); + }); + + it('should return first version when it is not draft (versionId is not "0")', () => { + const flexVersions: FlexVersion[] = [ + { versionId: '1.0.0' } as FlexVersion, + { versionId: '00025E29EA041FD0BB9495569AC3D2AD' } as FlexVersion + ]; + expect(determineFlexVersion(flexVersions)).toBe('1.0.0'); + }); + + it('should return empty string when array is empty or null/undefined', () => { + expect(determineFlexVersion([])).toBe(''); + expect(determineFlexVersion(null as any)).toBe(''); + expect(determineFlexVersion(undefined as any)).toBe(''); + }); + + it('should return empty string when only one version exists and it is "0"', () => { + const flexVersions: FlexVersion[] = [{ versionId: '0' } as FlexVersion]; + expect(determineFlexVersion(flexVersions)).toBe(''); + }); +}); diff --git a/packages/generator-adp/test/unit/utils/steps.test.ts b/packages/generator-adp/test/unit/utils/steps.test.ts index 2c49352d408..58317a36596 100644 --- a/packages/generator-adp/test/unit/utils/steps.test.ts +++ b/packages/generator-adp/test/unit/utils/steps.test.ts @@ -9,7 +9,8 @@ import { updateWizardSteps, updateFlpWizardSteps, getSubGenErrorPage, - getSubGenAuthPages + getSubGenAuthPages, + getKeyUserImportPage } from '../../../src/utils/steps'; import { initI18n, t } from '../../../src/utils/i18n'; @@ -32,6 +33,11 @@ describe('Wizard Steps Utility', () => { expect(steps.map((s) => s.name)).toContain(flpStep.name); }); + it('returns key user import page definition', () => { + const keyUserStep = getKeyUserImportPage(); + expect(keyUserStep.name).toBe(t('yuiNavSteps.keyUserImportName')); + }); + it('should not add the step twice if it already exists', () => { const flpStep = getFlpPages(false, 'TestProject')[0]; updateWizardSteps(prompts, flpStep, t('yuiNavSteps.projectAttributesName'), true);