diff --git a/README.md b/README.md index ea3ba30..2123623 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,7 @@ Important: for process events defined on 'DELETE' operation, a before handler fe - `@bpm.process.start.on` - `@bpm.process.start.if` -- only starting process if expression is true - `@bpm.process.start.inputs` -- array of input mappings that control which entity fields are passed as process context (optional) - -**Important:** The target process must have an input attribute `businesskey` of type string. The entity's key is used as the `businesskey` value, which links the process instance to the entity for later CANCEL/SUSPEND/RESUME operations. +- if a businessKey is annotated on the entity using `@bpm.process.businessKey`, at process start this businessKey expression will be evaluated. If the length of the businessKey exceeds SBPAs character limit of 255, the request will also be rejected as process start will fail for that case ### Input Mapping @@ -313,6 +312,8 @@ service MyService { - `@bpm.process..cascade` -- boolean (optional, defaults to false) - `@bpm.process..if` -- only starting process if expression is true - example: `@bpm.process.suspend.if: (weight > 10)` +- for cancelling/resuming/suspending it is required to have a businessKey expression annotated on the entity using `@bpm.process.businessKey`. If no businessKey is annotated, the request will be rejected + - example: `@bpm.process.businessKey: (id || '-' || name)` Example: @@ -381,6 +382,9 @@ When both `@bpm.process.start.id` and `@bpm.process.start.on` are present and th - A bound action defined on the entity - `@bpm.process..cascade` is optional (defaults to false); if provided, must be a boolean - `@bpm.process..if` must be a valid CDS expression (if present) +- if any annotation with `@bpm.process.` is defined, a valid businessKey expression must be defined using `@bpm.process.businessKey` + - example: `@bpm.process.businessKey: (id || '-' || name)` would concatenate id and name with a '-' string as a business key + - the businessKey definition here must reflect the one configured in SBPA Process Builder ### Warnings diff --git a/lib/build/constants.ts b/lib/build/constants.ts index 402cbbc..1c4337c 100644 --- a/lib/build/constants.ts +++ b/lib/build/constants.ts @@ -32,6 +32,13 @@ export const ERROR_IF_MUST_BE_EXPRESSION = (entityName: string, annotationIf: st return `${entityName}: ${annotationIf} must be a valid expression`; }; +export const ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION = ( + entityName: string, + annotationBKey: string, +): string => { + return `${entityName}: ${annotationBKey} must be a valid expression`; +}; + // ============================================================================= // Start Annotation Validation Messages // ============================================================================= @@ -84,6 +91,9 @@ export const ERROR_ON_REQUIRED = ( return `${entityName}: ${annotationPrefix} requires ${annotationOn} to be defined`; }; +export const ERROR_BUSINESS_KEY_REQUIRED = (entityName: string, annotationPrefix: string) => + `Entity "${entityName}" must have a business key defined when using the "${annotationPrefix}" annotation.`; + export const ERROR_CASCADE_MUST_BE_BOOLEAN = ( entityName: string, annotationCascade: string, diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index 4d1d701..745f41b 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -9,6 +9,7 @@ import { validateRequiredGenericAnnotations, validateRequiredStartAnnotations, validateIfAnnotation, + validateBusinessKeyAnnotation, } from './index'; import { PROCESS_START_ID, @@ -29,6 +30,7 @@ import { PROCESS_RESUME, PROCESS_START, PROCESS_PREFIX, + BUSINESS_KEY, } from '../constants'; import { CsnDefinition, CsnEntity } from '../../types/csn-extensions'; @@ -183,6 +185,7 @@ export class ProcessValidationPlugin extends BuildPluginBase { const hasOn = def[annotationOn] !== undefined; const hasCascade = def[annotationCascade] !== undefined; const hasIf = def[annotationIf] !== undefined; + const hasBusinessKey = def[BUSINESS_KEY] !== undefined; const hasAnyAnnotationWithPrefix = Object.keys(def).some((key) => key.startsWith(annotationPrefix + '.'), @@ -195,6 +198,7 @@ export class ProcessValidationPlugin extends BuildPluginBase { entityName, annotationOn, annotationPrefix, + hasBusinessKey, this, ); @@ -209,5 +213,9 @@ export class ProcessValidationPlugin extends BuildPluginBase { if (hasIf) { validateIfAnnotation(def, entityName, annotationIf, this); } + + if (hasOn && hasBusinessKey) { + validateBusinessKeyAnnotation(def, entityName, this); + } } } diff --git a/lib/build/validations.ts b/lib/build/validations.ts index fb832fa..d374321 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -1,7 +1,7 @@ import cds from '@sap/cds'; import { ProcessValidationPlugin } from './plugin'; import { CsnDefinition, CsnElement, CsnEntity } from '../../types/csn-extensions'; -import { PROCESS_START_ID, PROCESS_START_ON } from '../constants'; +import { BUSINESS_KEY, PROCESS_START_ID, PROCESS_START_ON } from '../constants'; import { createCsnEntityContext, ElementType, @@ -18,6 +18,7 @@ import { ERROR_START_ID_REQUIRES_ON, ERROR_START_ID_MUST_BE_STRING, ERROR_ON_REQUIRED, + ERROR_BUSINESS_KEY_REQUIRED, WARNING_TYPE_MISMATCH, WARNING_ARRAY_MISMATCH, ERROR_MISSING_MANDATORY_PROCESS_INPUT, @@ -27,6 +28,7 @@ import { WARNING_NO_PROCESS_DEFINITION, ERROR_START_BUSINESSKEY_INPUT_MISSING, WARNING_INPUT_PATH_NOT_IN_ENTITY, + ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION, } from './constants'; import { EntityContext, ParsedInputEntry } from '../shared/input-parser'; @@ -83,6 +85,16 @@ export function validateIfAnnotation( buildPlugin.pushMessage(ERROR_IF_MUST_BE_EXPRESSION(entityName, annotationIf), ERROR); } } +export function validateBusinessKeyAnnotation( + def: CsnEntity, + entityName: string, + buildPlugin: ProcessValidationPlugin, +) { + const bKeyExpr = def[BUSINESS_KEY]; + if (!bKeyExpr || !bKeyExpr['='] || (!bKeyExpr['xpr'] && !bKeyExpr['ref'])) { + buildPlugin.pushMessage(ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, BUSINESS_KEY), ERROR); + } +} export function validateCascadeAnnotation( def: CsnEntity, @@ -138,12 +150,18 @@ export function validateRequiredGenericAnnotations( entityName: string, annotationOn: string, annotationPrefix: string, + hasBusinessKey: boolean, buildPlugin: ProcessValidationPlugin, ) { // If any annotation with this prefix is defined, .on is required if (hasAnyAnnotationWithPrefix && !hasOn) { buildPlugin.pushMessage(ERROR_ON_REQUIRED(entityName, annotationPrefix, annotationOn), ERROR); } + // If .on is defined or any annotation with this prefix is defined, + // businessKey must exist + if ((hasOn || hasAnyAnnotationWithPrefix) && !hasBusinessKey) { + buildPlugin.pushMessage(ERROR_BUSINESS_KEY_REQUIRED(entityName, annotationPrefix), ERROR); + } } export function validateInputTypes( diff --git a/lib/constants.ts b/lib/constants.ts index 113bf84..1e86b29 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -13,6 +13,17 @@ export const PROCESS_LOGGER_PREFIX = 'process' as const; */ export const CUD_EVENTS = ['CREATE', 'UPDATE', 'DELETE'] as const; +/** Business Key Annotations + * + */ +export const BUSINESS_KEY = '@bpm.process.businessKey' as const; +export const BUSINESS_KEY_MAX_LENGTH = 255; + +/** + * Business key alias const + */ +export const BUSINESS_KEY_ALIAS = 'as businessKey' as const; + /** * Process Start Annotations */ diff --git a/lib/handlers/index.ts b/lib/handlers/index.ts index 2cfccdf..beb310f 100644 --- a/lib/handlers/index.ts +++ b/lib/handlers/index.ts @@ -8,12 +8,5 @@ export { handleProcessSuspend, addDeletedEntityToRequestSuspend } from './proces export { handleProcessResume, addDeletedEntityToRequestResume } from './processResume'; export { createProcessActionHandler } from './processActionHandler'; export { registerProcessServiceHandlers } from './processService'; -export { getKeyFieldsForEntity, concatenateBusinessKey } from './utils'; -export type { - EntityRow, - ProcessStartPayload, - ProcessLifecyclePayload, - ProcessEventType, - AnnotatedTarget, -} from './utils'; +export type { EntityRow, ProcessStartPayload, ProcessLifecyclePayload } from './utils'; export type { ProcessDeleteRequest } from './onDeleteUtils'; diff --git a/lib/handlers/onDeleteUtils.ts b/lib/handlers/onDeleteUtils.ts index d646b2c..11456e7 100644 --- a/lib/handlers/onDeleteUtils.ts +++ b/lib/handlers/onDeleteUtils.ts @@ -1,7 +1,9 @@ -import cds, { column_expr, expr, Results } from '@sap/cds'; -import { PROCESS_LOGGER_PREFIX } from '../constants'; +import cds, { column_expr, expr, Results, Target } from '@sap/cds'; +import { BUSINESS_KEY, PROCESS_LOGGER_PREFIX } from '../constants'; import { EntityRow } from './utils'; import { WILDCARD } from '../shared/input-parser'; +import { getColumnsForProcessStart } from './processStart'; +import { getBusinessKeyColumn } from '../shared/businessKey-helper'; const LOG = cds.log(PROCESS_LOGGER_PREFIX); @@ -90,3 +92,16 @@ export function createAddDeletedEntityHandler(config: AddDeletedEntityConfig) { } }; } + +export function addBusinessKeyToStartColumns(req: cds.Request): (column_expr | string)[] { + const target = req.target as Target; + const startColumns = getColumnsForProcessStart(target); + + const businessKeyColumns = getBusinessKeyColumn((target[BUSINESS_KEY] as { '=': string })?.['=']); + + if (businessKeyColumns) { + startColumns.push(businessKeyColumns); + } + + return startColumns; +} diff --git a/lib/handlers/processActionHandler.ts b/lib/handlers/processActionHandler.ts index 2aa57df..9010ae5 100644 --- a/lib/handlers/processActionHandler.ts +++ b/lib/handlers/processActionHandler.ts @@ -3,9 +3,7 @@ import { expr, Target } from '@sap/cds'; import { emitProcessEvent, EntityRow, - getBusinessKeyOrReject, getEntityDataFromRequest, - getKeyFieldsForEntity, ProcessLifecyclePayload, resolveEntityRowOrReject, } from './utils'; @@ -15,6 +13,8 @@ import { PROCESS_EVENT_MAP, ProcessDeleteRequest, } from './onDeleteUtils'; +import { getBusinessKeyColumnOrReject } from '../shared/businessKey-helper'; +import { BUSINESS_KEY } from '../constants'; type ProcessActionType = 'cancel' | 'resume' | 'suspend'; @@ -22,6 +22,7 @@ interface ProcessActionSpec { on?: string; cascade: boolean; conditionExpr: expr | undefined; + businessKey: string | undefined; } interface ProcessActionConfig { @@ -57,6 +58,7 @@ function initSpecs( conditionExpr: targetAnnotations[annotations.IF] ? (targetAnnotations[annotations.IF] as { xpr: expr }).xpr : undefined, + businessKey: targetAnnotations[BUSINESS_KEY]?.['='], }; } @@ -71,6 +73,10 @@ export function createProcessActionHandler(config: ProcessActionConfig) { // Initialize specifications from annotations const specs = initSpecs(target, config.annotations); + // Get business key column + const businessKeyColumn = getBusinessKeyColumnOrReject(req, specs.businessKey); + if (!businessKeyColumn) return; + // fetch entity const row = await resolveEntityRowOrReject( req, @@ -78,21 +84,16 @@ export function createProcessActionHandler(config: ProcessActionConfig) { specs.conditionExpr, config.logMessages.FETCH_FAILED, config.logMessages.NOT_TRIGGERED, + [businessKeyColumn], ); if (!row) return; - // Get business key - const businessKey = getBusinessKeyOrReject( - target as cds.entity, - row, - req, - config.logMessages.INVALID_KEY, - config.logMessages.EMPTY_KEY, - ); - if (!businessKey) return; - // Emit process event - const payload: ProcessLifecyclePayload = { businessKey, cascade: specs.cascade }; + + const payload: ProcessLifecyclePayload = { + businessKey: (row as { businessKey: string }).businessKey, + cascade: specs.cascade, + }; await emitProcessEvent(config.action, req, payload, config.logMessages.FAILED); }; } @@ -101,6 +102,8 @@ export function createProcessActionAddDeletedEntityHandler(config: ProcessAction return createAddDeletedEntityHandler({ action: config.action, ifAnnotation: config.annotations.IF, - getColumns: (req) => getKeyFieldsForEntity(req.target as cds.entity), + getColumns: (req) => [ + getBusinessKeyColumnOrReject(req, (req.target as cds.entity)[BUSINESS_KEY]?.['=']), + ], }); } diff --git a/lib/handlers/processStart.ts b/lib/handlers/processStart.ts index b169fe5..710f6f5 100644 --- a/lib/handlers/processStart.ts +++ b/lib/handlers/processStart.ts @@ -2,7 +2,6 @@ import { column_expr, expr, Target } from '@sap/cds'; import { emitProcessEvent, EntityRow, - getBusinessKeyOrReject, getEntityDataFromRequest, resolveEntityRowOrReject, } from './utils'; @@ -13,6 +12,8 @@ import { PROCESS_START_INPUTS, LOG_MESSAGES, PROCESS_LOGGER_PREFIX, + BUSINESS_KEY, + BUSINESS_KEY_MAX_LENGTH, } from './../constants'; import { InputCSNEntry, @@ -25,11 +26,13 @@ import { import cds from '@sap/cds'; import { + addBusinessKeyToStartColumns, createAddDeletedEntityHandler, isDeleteWithoutProcess, PROCESS_EVENT_MAP, ProcessDeleteRequest, } from './onDeleteUtils'; +import { getBusinessKeyColumn } from '../shared/businessKey-helper'; const LOG = cds.log(PROCESS_LOGGER_PREFIX); // Use InputTreeNode as ProcessStartInput (same structure) @@ -73,6 +76,11 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro columns = convertToColumnsExpr(startSpecs.inputs); } + const businessKeyColumn = getBusinessKeyColumn((target[BUSINESS_KEY] as { '=': string })?.['=']); + if (businessKeyColumn) { + columns.push(businessKeyColumn); + } + // fetch entity const row = await resolveEntityRowOrReject( req, @@ -84,20 +92,16 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro ); if (!row) return; - // get business key - const businessKey = getBusinessKeyOrReject( - target as cds.entity, - row, - req, - 'Failed to build business key for process start.', - 'Business key is empty for process start.', - ); - if (!businessKey) return; - - const context = { ...row, businesskey: businessKey }; + if ((row.businessKey as string)?.length > BUSINESS_KEY_MAX_LENGTH) { + const msg = `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters. Process start will fail.`; + LOG.error(msg); + return req.reject({ status: 400, message: msg }); + } else { + delete row.businessKey; + } // emit process start - const payload = { definitionId: startSpecs.id!, context }; + const payload = { definitionId: startSpecs.id!, context: row }; await emitProcessEvent( 'start', req, @@ -112,7 +116,7 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro export const addDeletedEntityToRequestStart = createAddDeletedEntityHandler({ action: 'start', ifAnnotation: PROCESS_START_IF, - getColumns: (req) => getColumnsForProcessStart(req.target as Target), + getColumns: (req) => addBusinessKeyToStartColumns(req), }); function initStartSpecs(target: Target): ProcessStartSpec { diff --git a/lib/handlers/utils.ts b/lib/handlers/utils.ts index 7aaed31..e792119 100644 --- a/lib/handlers/utils.ts +++ b/lib/handlers/utils.ts @@ -1,8 +1,13 @@ -import cds, { column_expr, expr, Target } from '@sap/cds'; +import cds, { column_expr, expr } from '@sap/cds'; import { PROCESS_LOGGER_PREFIX, PROCESS_SERVICE } from '../constants'; const { SELECT } = cds.ql; const LOG = cds.log(PROCESS_LOGGER_PREFIX); +/** + * Process event types supported by the system + */ +type ProcessEventType = 'start' | 'cancel' | 'suspend' | 'resume'; + /** * A row of entity data with string-keyed fields */ @@ -26,68 +31,11 @@ export interface ProcessLifecyclePayload { cascade: boolean; } -/** - * Process event types supported by the system - */ -export type ProcessEventType = 'start' | 'cancel' | 'suspend' | 'resume'; - -/** - * Extended CDS Target with annotation access - */ -export type AnnotatedTarget = Target & { - [key: `@${string}`]: unknown; -}; - -/** - * Extracts key field names from a CDS entity - */ -export function getKeyFieldsForEntity(entity: cds.entity): string[] { - const keys = entity.keys; - const result: string[] = []; - for (const key in keys) { - result.push(key); - } - return result; -} - -/** - * Concatenates all key field values into a single business key string - */ -export function concatenateBusinessKey(target: cds.entity, row: EntityRow): string { - let businessKey = ''; - for (const keyField of getKeyFieldsForEntity(target)) { - businessKey += String(row[keyField] ?? ''); - } - return businessKey; -} - -/** - * Extracts entity data from a CDS request. - * For CRUD operations, returns data directly. - * For bound actions, merges params (entity keys) with data (action inputs). - */ -export function getEntityDataFromRequest( - data: EntityRow, - reqParams: Record[], -): EntityRow { - if (reqParams && Array.isArray(reqParams) && reqParams.length > 0) { - const paramsData = reqParams.reduce((acc: EntityRow, param) => { - if (typeof param === 'object' && param !== null) { - return { ...acc, ...param }; - } - return acc; - }, {}); - return { ...data, ...paramsData }; - } - - return data; -} - async function fetchEntity( results: EntityRow, request: cds.Request, condition: expr | undefined, - columns?: (column_expr | string)[], + columns: (column_expr | string)[], ): Promise { if (typeof results !== 'object') { results = {}; @@ -100,7 +48,7 @@ async function fetchEntity( const fetchedData = await SELECT.one .from(request.target.name) - .columns(columns ? columns : keyFields) + .columns(columns) // eslint-disable-next-line @typescript-eslint/no-explicit-any .where(where as any); @@ -141,6 +89,40 @@ function buildWhereClause( return where; } +/** + * Extracts key field names from a CDS entity + */ +function getKeyFieldsForEntity(entity: cds.entity): string[] { + const keys = entity.keys; + const result: string[] = []; + for (const key in keys) { + result.push(key); + } + return result; +} + +/** + * Extracts entity data from a CDS request. + * For CRUD operations, returns data directly. + * For bound actions, merges params (entity keys) with data (action inputs). + */ +export function getEntityDataFromRequest( + data: EntityRow, + reqParams: Record[], +): EntityRow { + if (reqParams && Array.isArray(reqParams) && reqParams.length > 0) { + const paramsData = reqParams.reduce((acc: EntityRow, param) => { + if (typeof param === 'object' && param !== null) { + return { ...acc, ...param }; + } + return acc; + }, {}); + return { ...data, ...paramsData }; + } + + return data; +} + /** * Fetches entity data or rejects the request with appropriate error * Returns undefined if condition is not met (expected case) or if request was rejected @@ -151,23 +133,18 @@ export async function resolveEntityRowOrReject( conditionExpr: expr | undefined, fetchFailedMsg: string, notTriggeredMsg: string, - columns?: (column_expr | string)[], + columns: (column_expr | string)[], ): Promise { let row: EntityRow | undefined; try { - row = - req.event === 'DELETE' - ? data - : await fetchEntity(data, req, conditionExpr, columns ?? undefined); + row = req.event === 'DELETE' ? data : await fetchEntity(data, req, conditionExpr, columns); } catch (error) { LOG.error(fetchFailedMsg, error); - req.reject({ + return req.reject({ status: 500, message: fetchFailedMsg, }); - return undefined; } - if (!row) { LOG.debug(notTriggeredMsg); return undefined; @@ -177,35 +154,7 @@ export async function resolveEntityRowOrReject( } /** - * Extracts business key from entity row or rejects the request - * Returns undefined if request was rejected - */ -export function getBusinessKeyOrReject( - target: cds.entity, - row: EntityRow, - req: cds.Request, - invalidKeyMsg: string, - emptyKeyMsg: string, -): string | undefined { - let businessKey: string; - try { - businessKey = concatenateBusinessKey(target, { ...row, ...req.data }); - } catch (error) { - LOG.error(invalidKeyMsg, error); - req.reject({ status: 400, message: invalidKeyMsg }); - return undefined; - } - - if (!businessKey) { - req.reject({ status: 400, message: emptyKeyMsg }); - return undefined; - } - - return businessKey; -} - -/** - * Emits a process event to the outboxed ProcessService + * Emits a process event to the queued ProcessService */ export async function emitProcessEvent( event: ProcessEventType, @@ -219,6 +168,6 @@ export async function emitProcessEvent( await queuedProcessService.emit(event, payload); } catch (error) { LOG.error(processEventFailedMsg, error); - req.reject({ status: 500, message: processEventFailedMsg }); + return req.reject({ status: 500, message: processEventFailedMsg }); } } diff --git a/lib/shared/businessKey-helper.ts b/lib/shared/businessKey-helper.ts new file mode 100644 index 0000000..565cf5c --- /dev/null +++ b/lib/shared/businessKey-helper.ts @@ -0,0 +1,22 @@ +import cds from '@sap/cds'; +import { BUSINESS_KEY_ALIAS, PROCESS_LOGGER_PREFIX } from '../constants'; + +const LOG = cds.log(PROCESS_LOGGER_PREFIX); + +function formatBusinessKeyColumn(businessKey: string) { + return `${businessKey} ${BUSINESS_KEY_ALIAS}`; +} + +export function getBusinessKeyColumnOrReject(req: cds.Request, businessKey: string | undefined) { + if (!businessKey) { + const msg = 'Business key is required but was not found in the entity.'; + LOG.error(msg); + req.reject({ status: 400, message: msg }); + } else { + return formatBusinessKeyColumn(businessKey); + } +} + +export function getBusinessKeyColumn(businessKey: string | undefined) { + return businessKey ? formatBusinessKeyColumn(businessKey) : undefined; +} diff --git a/srv/localProcessService.ts b/srv/localProcessService.ts index 5827a4d..d45054e 100644 --- a/srv/localProcessService.ts +++ b/srv/localProcessService.ts @@ -14,7 +14,7 @@ class ProcessService extends cds.ApplicationService { LOG.debug( `==============================================================\n` + `Process start for ${definitionId} initiated\n` + - `BusinessKey: ${businessKey}\nContext: ${JSON.stringify(context, null, 2)}\n` + + `Context: ${JSON.stringify(context, null, 2)}\n` + `==============================================================`, ); diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index ed0f583..785e1ad 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -70,8 +70,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'UPDATE', cascade: false, - if: (mileage > 1000) + if: (mileage > 1000), } + @bpm.process.businessKey: (ID) entity CarWhen as projection on my.Car { ID, @@ -194,6 +195,7 @@ service AnnotationService { on: 'CREATE', cascade: false, } + @bpm.process.businessKey: (ID) entity CancelOnCreate as projection on my.Car { ID, @@ -207,8 +209,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'CREATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity CancelOnCreateWhen as projection on my.Car { ID, @@ -223,6 +226,7 @@ service AnnotationService { on: 'UPDATE', cascade: false, } + @bpm.process.businessKey: (ID) entity CancelOnUpdate as projection on my.Car { ID, @@ -236,8 +240,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'UPDATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity CancelOnUpdateWhen as projection on my.Car { ID, @@ -252,6 +257,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity CancelOnDelete as projection on my.Car { ID, @@ -265,8 +271,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'DELETE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity CancelOnDeleteWhen as projection on my.Car { ID, @@ -285,6 +292,7 @@ service AnnotationService { on: 'CREATE', cascade: false, } + @bpm.process.businessKey: (ID) entity SuspendOnCreate as projection on my.Car { ID, @@ -298,8 +306,9 @@ service AnnotationService { @bpm.process.suspend: { on: 'CREATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity SuspendOnCreateWhen as projection on my.Car { ID, @@ -314,6 +323,7 @@ service AnnotationService { on: 'UPDATE', cascade: false, } + @bpm.process.businessKey: (ID) entity SuspendOnUpdate as projection on my.Car { ID, @@ -327,8 +337,9 @@ service AnnotationService { @bpm.process.suspend: { on: 'UPDATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity SuspendOnUpdateWhen as projection on my.Car { ID, @@ -343,6 +354,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity SuspendOnDelete as projection on my.Car { ID, @@ -356,8 +368,9 @@ service AnnotationService { @bpm.process.suspend: { on: 'DELETE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity SuspendOnDeleteWhen as projection on my.Car { ID, @@ -376,6 +389,7 @@ service AnnotationService { on: 'CREATE', cascade: false, } + @bpm.process.businessKey: (ID) entity ResumeOnCreate as projection on my.Car { ID, @@ -389,8 +403,9 @@ service AnnotationService { @bpm.process.resume: { on: 'CREATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity ResumeOnCreateWhen as projection on my.Car { ID, @@ -405,6 +420,7 @@ service AnnotationService { on: 'UPDATE', cascade: false, } + @bpm.process.businessKey: (ID) entity ResumeOnUpdate as projection on my.Car { ID, @@ -418,8 +434,9 @@ service AnnotationService { @bpm.process.resume: { on: 'UPDATE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity ResumeOnUpdateWhen as projection on my.Car { ID, @@ -434,6 +451,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity ResumeOnDelete as projection on my.Car { ID, @@ -447,8 +465,9 @@ service AnnotationService { @bpm.process.resume: { on: 'DELETE', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity ResumeOnDeleteWhen as projection on my.Car { ID, @@ -463,7 +482,8 @@ service AnnotationService { // ============================================ // Cancel on CREATE without cascade (should default to false) - @bpm.process.cancel: {on: 'CREATE', } + @bpm.process.cancel: {on: 'CREATE' } + @bpm.process.businessKey: (ID) entity CancelOnCreateDefaultCascade as projection on my.Car { ID, @@ -474,7 +494,8 @@ service AnnotationService { } // Cancel on UPDATE without cascade (should default to false) - @bpm.process.cancel: {on: 'UPDATE', } + @bpm.process.cancel: {on: 'UPDATE' } + @bpm.process.businessKey: (ID) entity CancelOnUpdateDefaultCascade as projection on my.Car { ID, @@ -485,7 +506,8 @@ service AnnotationService { } // Cancel on DELETE without cascade (should default to false) - @bpm.process.cancel: {on: 'DELETE', } + @bpm.process.cancel: {on: 'DELETE' } + @bpm.process.businessKey: (ID) entity CancelOnDeleteDefaultCascade as projection on my.Car { ID, @@ -496,7 +518,8 @@ service AnnotationService { } // Suspend on CREATE without cascade (should default to false) - @bpm.process.suspend: {on: 'CREATE', } + @bpm.process.suspend: {on: 'CREATE' } + @bpm.process.businessKey: (ID) entity SuspendOnCreateDefaultCascade as projection on my.Car { ID, @@ -507,7 +530,8 @@ service AnnotationService { } // Suspend on UPDATE without cascade (should default to false) - @bpm.process.suspend: {on: 'UPDATE', } + @bpm.process.suspend: {on: 'UPDATE' } + @bpm.process.businessKey: (ID) entity SuspendOnUpdateDefaultCascade as projection on my.Car { ID, @@ -518,7 +542,8 @@ service AnnotationService { } // Resume on CREATE without cascade (should default to false) - @bpm.process.resume: {on: 'CREATE', } + @bpm.process.resume: {on: 'CREATE' } + @bpm.process.businessKey: (ID) entity ResumeOnCreateDefaultCascade as projection on my.Car { ID, @@ -529,7 +554,8 @@ service AnnotationService { } // Resume on UPDATE without cascade (should default to false) - @bpm.process.resume: {on: 'UPDATE', } + @bpm.process.resume: {on: 'UPDATE' } + @bpm.process.businessKey: (ID) entity ResumeOnUpdateDefaultCascade as projection on my.Car { ID, @@ -556,6 +582,7 @@ service AnnotationService { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity BasicLifecycle as projection on my.Car { ID, @@ -577,8 +604,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'UPDATE', cascade: false, - if: (mileage > 1000) + if: (mileage > 1000), } + @bpm.process.businessKey: (ID) entity StatusBasedCancel as projection on my.Car { ID, @@ -601,13 +629,14 @@ service AnnotationService { @bpm.process.suspend: { on: 'UPDATE', cascade: false, - if: (mileage > 500) + if: (mileage > 500), } @bpm.process.resume: { on: 'UPDATE', cascade: false, - if: (mileage <= 500) + if: (mileage <= 500), } + @bpm.process.businessKey: (ID) entity SuspendResumeWorkflow as projection on my.Car { ID, @@ -629,17 +658,18 @@ service AnnotationService { @bpm.process.suspend: { on: 'UPDATE', cascade: false, - if: (mileage > 800) + if: (mileage > 800), } @bpm.process.resume: { on: 'UPDATE', cascade: false, - if: (mileage <= 800) + if: (mileage <= 800), } @bpm.process.cancel: { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity FullLifecycle as projection on my.Car { ID, @@ -662,8 +692,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'UPDATE', cascade: false, - if: (mileage > 1500) + if: (mileage > 1500), } + @bpm.process.businessKey: (ID) entity ConditionalStartCancel as projection on my.Car { ID, @@ -682,17 +713,18 @@ service AnnotationService { @bpm.process.suspend: { on: 'UPDATE', cascade: false, - if: (mileage > 500) + if: (mileage > 500), } @bpm.process.resume: { on: 'UPDATE', cascade: false, - if: (mileage <= 500) + if: (mileage <= 500), } @bpm.process.cancel: { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity ExternalWorkflowManagement as projection on my.Car { ID, @@ -718,6 +750,7 @@ service AnnotationService { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity DeleteStartCancel as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -733,6 +766,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity DeleteStartResume as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -748,6 +782,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity DeleteCancelResume as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -763,6 +798,7 @@ service AnnotationService { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity DeleteCancelSuspend as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -779,6 +815,7 @@ service AnnotationService { cascade: true, if: (mileage <= 500) } + @bpm.process.businessKey: (ID) entity DeleteCancelSuspendIfExpr as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -798,6 +835,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity DeleteStartCancelResume as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -821,6 +859,7 @@ service AnnotationService { on: 'DELETE', cascade: true, } + @bpm.process.businessKey: (ID) entity DeleteAllEvents as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -840,6 +879,7 @@ service AnnotationService { on: 'DELETE', cascade: false, } + @bpm.process.businessKey: (ID) entity DeleteStartInputsCancel as projection on my.Car { ID, model, manufacturer, mileage, year } @@ -1051,6 +1091,7 @@ service AnnotationService { on: 'triggerCancel', cascade: false, } + @bpm.process.businessKey: (ID) entity CancelOnAction as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1063,8 +1104,9 @@ service AnnotationService { @bpm.process.cancel: { on: 'triggerCancelWhen', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity CancelOnActionWhen as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1078,6 +1120,7 @@ service AnnotationService { on: 'triggerSuspend', cascade: false, } + @bpm.process.businessKey: (ID) entity SuspendOnAction as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1090,8 +1133,9 @@ service AnnotationService { @bpm.process.suspend: { on: 'triggerSuspendWhen', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity SuspendOnActionWhen as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1105,6 +1149,7 @@ service AnnotationService { on: 'triggerResume', cascade: false, } + @bpm.process.businessKey: (ID) entity ResumeOnAction as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1117,8 +1162,9 @@ service AnnotationService { @bpm.process.resume: { on: 'triggerResumeWhen', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity ResumeOnActionWhen as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1164,6 +1210,7 @@ service AnnotationService { on: '*', cascade: false, } + @bpm.process.businessKey: (ID) entity CancelOnWildcard as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1177,6 +1224,7 @@ service AnnotationService { on: '*', cascade: false, } + @bpm.process.businessKey: (ID) entity SuspendOnWildcard as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1190,6 +1238,7 @@ service AnnotationService { on: '*', cascade: false, } + @bpm.process.businessKey: (ID) entity ResumeOnWildcard as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1202,8 +1251,9 @@ service AnnotationService { @bpm.process.cancel: { on: '*', cascade: true, - if: (mileage > 500) + if: (mileage > 500), } + @bpm.process.businessKey: (ID) entity CancelOnWildcardWhen as projection on my.Car { ID, model, manufacturer, mileage, year } actions { @@ -1437,4 +1487,153 @@ service AnnotationService { key ID : UUID; name : String(100); } + + // ============================================ + // BUSINESS KEY LENGTH VALIDATION TESTS + // Testing businessKey max length (255 chars) on processStart + // ============================================ + + // Start on CREATE with businessKey (short value - well under 255 chars) + @bpm.process.start: { + id: 'startShortBusinessKeyProcess', + on: 'CREATE', + } + @bpm.process.businessKey: (ID) + entity StartWithShortBusinessKey as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Start on CREATE with businessKey at exactly 255 chars + @bpm.process.start: { + id: 'startExactLimitBusinessKeyProcess', + on: 'CREATE', + } + @bpm.process.businessKey: (longValue) + entity StartWithExactLimitBusinessKey { + key ID : UUID; + longValue : String(300); + name : String(100); + } + + // Start on CREATE with businessKey exceeding 255 chars + @bpm.process.start: { + id: 'startExceedingBusinessKeyProcess', + on: 'CREATE', + } + @bpm.process.businessKey: (longValue) + entity StartWithExceedingBusinessKey { + key ID : UUID; + longValue : String(300); + name : String(100); + } + + // Start on DELETE with businessKey exceeding 255 chars + @bpm.process.start: { + id: 'startOnDeleteExceedingBusinessKeyProcess', + on: 'DELETE', + } + @bpm.process.businessKey: (longValue) + entity StartOnDeleteExceedingBusinessKey { + key ID : UUID; + longValue : String(300); + name : String(100); + } + + // Start on UPDATE with businessKey exceeding 255 chars + @bpm.process.start: { + id: 'startOnUpdateExceedingBusinessKeyProcess', + on: 'UPDATE', + } + @bpm.process.businessKey: (longValue) + entity StartOnUpdateExceedingBusinessKey { + key ID : UUID; + longValue : String(300); + name : String(100); + } + + // ============================================ + // COMPOSITE BUSINESS KEY TESTS + // Testing businessKey with concat expressions + // ============================================ + + // Cancel with businessKey composed from two fields + @bpm.process.cancel: { + on: 'DELETE', + cascade: false, + } + @bpm.process.businessKey: (model || '-' || manufacturer) + entity CancelCompositeKey as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Suspend with businessKey composed from two fields + @bpm.process.suspend: { + on: 'UPDATE', + cascade: false, + if: (mileage > 500), + } + @bpm.process.businessKey: (manufacturer || '_' || model) + entity SuspendCompositeKey as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Resume with businessKey composed from two fields + @bpm.process.resume: { + on: 'UPDATE', + cascade: false, + if: (mileage <= 500), + } + @bpm.process.businessKey: (manufacturer || '_' || model) + entity ResumeCompositeKey as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + + // Full lifecycle with composite businessKey on all action annotations + @bpm.process.start: { + id: 'compositeKeyLifecycleProcess', + on: 'CREATE', + } + @bpm.process.suspend: { + on: 'UPDATE', + cascade: false, + if: (mileage > 500), + } + @bpm.process.resume: { + on: 'UPDATE', + cascade: false, + if: (mileage <= 500), + } + @bpm.process.cancel: { + on: 'DELETE', + cascade: true, + } + @bpm.process.businessKey: (model || '/' || manufacturer) + entity CompositeKeyLifecycle as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } } diff --git a/tests/integration/annotations/businessKey.test.ts b/tests/integration/annotations/businessKey.test.ts new file mode 100644 index 0000000..ed68847 --- /dev/null +++ b/tests/integration/annotations/businessKey.test.ts @@ -0,0 +1,465 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cds from '@sap/cds'; +const { join } = cds.utils.path; + +const app = join(__dirname, '../../bookshop'); +const { test, POST, DELETE, PATCH } = cds.test(app); + +const BUSINESS_KEY_MAX_LENGTH = 255; + +let foundMessages: any[] = []; + +beforeAll(async () => { + const db = await cds.connect.to('db'); + db.before('*', (req) => { + if (req.event === 'CREATE' && req.target?.name === 'cds.outbox.Messages') { + const msg = JSON.parse(req.query?.INSERT?.entries[0].msg); + foundMessages.push(msg); + } + }); +}); + +beforeEach(async () => { + await test.data.reset(); + foundMessages = []; +}); + +describe('Integration tests for Business Key Length Validation on processStart', () => { + // ================================================ + // START ON CREATE - businessKey length validation + // ================================================ + describe('Start on CREATE with businessKey length validation', () => { + it('should start process when businessKey is well under 255 characters', async () => { + const car = { + ID: '550e8400-e29b-41d4-a716-446655440000', + model: 'Test Model', + manufacturer: 'Test Manufacturer', + mileage: 100, + year: 2020, + }; + + const response = await POST('/odata/v4/annotation/StartWithShortBusinessKey', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startShortBusinessKeyProcess'); + expect(foundMessages[0].data.context).toBeDefined(); + }); + + it('should start process when businessKey is exactly 255 characters', async () => { + const exactLengthValue = 'a'.repeat(BUSINESS_KEY_MAX_LENGTH); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440001', + longValue: exactLengthValue, + name: 'Test', + }; + + const response = await POST('/odata/v4/annotation/StartWithExactLimitBusinessKey', entity); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startExactLimitBusinessKeyProcess'); + expect(foundMessages[0].data.context).toBeDefined(); + }); + + it('should reject with 400 when businessKey exceeds 255 characters', async () => { + const exceedingValue = 'a'.repeat(BUSINESS_KEY_MAX_LENGTH + 1); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440002', + longValue: exceedingValue, + name: 'Test', + }; + + try { + await POST('/odata/v4/annotation/StartWithExceedingBusinessKey', entity); + fail('Expected request to be rejected'); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error.message).toContain( + `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters`, + ); + } + + expect(foundMessages.length).toBe(0); + }); + + it('should reject with 400 when businessKey is significantly over the limit', async () => { + const longValue = 'x'.repeat(300); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440003', + longValue, + name: 'Test', + }; + + try { + await POST('/odata/v4/annotation/StartWithExceedingBusinessKey', entity); + fail('Expected request to be rejected'); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error.message).toContain( + `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters`, + ); + } + + expect(foundMessages.length).toBe(0); + }); + }); + + // ================================================ + // START ON DELETE - businessKey length validation + // ================================================ + describe('Start on DELETE with businessKey length validation', () => { + it('should reject with 400 on DELETE when businessKey exceeds 255 characters', async () => { + const exceedingValue = 'a'.repeat(BUSINESS_KEY_MAX_LENGTH + 1); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440004', + longValue: exceedingValue, + name: 'Test', + }; + + // First create the entity + const createResponse = await POST( + '/odata/v4/annotation/StartOnDeleteExceedingBusinessKey', + entity, + ); + expect(createResponse.status).toBe(201); + foundMessages = []; + + // Delete should fail due to businessKey length + try { + await DELETE(`/odata/v4/annotation/StartOnDeleteExceedingBusinessKey('${entity.ID}')`); + fail('Expected request to be rejected'); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error.message).toContain( + `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters`, + ); + } + + expect(foundMessages.length).toBe(0); + }); + + it('should start process on DELETE when businessKey is within the limit', async () => { + const validValue = 'd'.repeat(BUSINESS_KEY_MAX_LENGTH); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440009', + longValue: validValue, + name: 'Test', + }; + + // First create the entity + const createResponse = await POST( + '/odata/v4/annotation/StartOnDeleteExceedingBusinessKey', + entity, + ); + expect(createResponse.status).toBe(201); + foundMessages = []; + + // Delete should succeed since businessKey is within limit + const deleteResponse = await DELETE( + `/odata/v4/annotation/StartOnDeleteExceedingBusinessKey('${entity.ID}')`, + ); + + expect(deleteResponse.status).toBe(204); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startOnDeleteExceedingBusinessKeyProcess'); + }); + }); + + // ================================================ + // START ON UPDATE - businessKey length validation + // ================================================ + describe('Start on UPDATE with businessKey length validation', () => { + it('should reject with 400 on UPDATE when businessKey exceeds 255 characters', async () => { + const exceedingValue = 'a'.repeat(BUSINESS_KEY_MAX_LENGTH + 1); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440005', + longValue: exceedingValue, + name: 'Test', + }; + + // First create the entity + const createResponse = await POST( + '/odata/v4/annotation/StartOnUpdateExceedingBusinessKey', + entity, + ); + expect(createResponse.status).toBe(201); + foundMessages = []; + + // Update should fail due to businessKey length + try { + await PATCH(`/odata/v4/annotation/StartOnUpdateExceedingBusinessKey('${entity.ID}')`, { + name: 'Updated', + }); + fail('Expected request to be rejected'); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error.message).toContain( + `Business key value exceeds maximum length of ${BUSINESS_KEY_MAX_LENGTH} characters`, + ); + } + + expect(foundMessages.length).toBe(0); + }); + + it('should start process on UPDATE when businessKey is within the limit', async () => { + const validValue = 'b'.repeat(BUSINESS_KEY_MAX_LENGTH); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440006', + longValue: validValue, + name: 'Test', + }; + + // First create the entity + const createResponse = await POST( + '/odata/v4/annotation/StartOnUpdateExceedingBusinessKey', + entity, + ); + expect(createResponse.status).toBe(201); + foundMessages = []; + + // Update should succeed since businessKey is within limit + const updateResponse = await PATCH( + `/odata/v4/annotation/StartOnUpdateExceedingBusinessKey('${entity.ID}')`, + { name: 'Updated' }, + ); + + expect(updateResponse.status).toBe(200); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startOnUpdateExceedingBusinessKeyProcess'); + }); + }); + + // ================================================ + // BOUNDARY VALUE TESTS + // ================================================ + describe('Boundary value tests for businessKey length', () => { + it('should start process when businessKey is exactly 254 characters (one below limit)', async () => { + const value = 'c'.repeat(BUSINESS_KEY_MAX_LENGTH - 1); + + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440007', + longValue: value, + name: 'Test', + }; + + const response = await POST('/odata/v4/annotation/StartWithExactLimitBusinessKey', entity); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startExactLimitBusinessKeyProcess'); + }); + + it('should start process when businessKey is exactly 1 character', async () => { + const entity = { + ID: '550e8400-e29b-41d4-a716-446655440008', + longValue: 'x', + name: 'Test', + }; + + const response = await POST('/odata/v4/annotation/StartWithExactLimitBusinessKey', entity); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('startExactLimitBusinessKeyProcess'); + }); + }); +}); + +describe('Integration tests for Composite Business Key', () => { + // Helper function to create a test car entity + const createTestCar = (id?: string, mileage: number = 100) => ({ + ID: id || '550e8400-e29b-41d4-a716-446655440000', + model: 'Test Model', + manufacturer: 'Test Manufacturer', + mileage, + year: 2020, + }); + + const findMessagesByEvent = (eventName: string) => + foundMessages.filter((msg) => msg.event === eventName); + const findCancelMessages = () => findMessagesByEvent('cancel'); + const findSuspendMessages = () => findMessagesByEvent('suspend'); + const findResumeMessages = () => findMessagesByEvent('resume'); + + // ================================================ + // CANCEL WITH COMPOSITE BUSINESS KEY + // businessKey: (concat(model, concat('-', manufacturer))) + // ================================================ + describe('Cancel with composite businessKey (model-manufacturer)', () => { + it('should cancel with concatenated businessKey on DELETE', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/CancelCompositeKey', car); + + foundMessages = []; + + await DELETE(`/odata/v4/annotation/CancelCompositeKey('${car.ID}')`); + + const cancelMessages = findCancelMessages(); + expect(cancelMessages.length).toBe(1); + expect(cancelMessages[0].data).toEqual({ + businessKey: `${car.model}-${car.manufacturer}`, + cascade: false, + }); + }); + }); + + // ================================================ + // SUSPEND WITH COMPOSITE BUSINESS KEY + // businessKey: (concat(manufacturer, concat('_', model))) + // ================================================ + describe('Suspend with composite businessKey (manufacturer_model)', () => { + it('should suspend with concatenated businessKey on UPDATE when condition met', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/SuspendCompositeKey', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/SuspendCompositeKey('${car.ID}')`, { + mileage: 600, // mileage > 500 + }); + + const suspendMessages = findSuspendMessages(); + expect(suspendMessages.length).toBe(1); + expect(suspendMessages[0].data).toEqual({ + businessKey: `${car.manufacturer}_${car.model}`, + cascade: false, + }); + }); + + it('should NOT suspend when condition is NOT met', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/SuspendCompositeKey', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/SuspendCompositeKey('${car.ID}')`, { + mileage: 400, // mileage <= 500 + }); + + const suspendMessages = findSuspendMessages(); + expect(suspendMessages.length).toBe(0); + }); + }); + + // ================================================ + // RESUME WITH COMPOSITE BUSINESS KEY + // businessKey: (concat(manufacturer, concat('_', model))) + // ================================================ + describe('Resume with composite businessKey (manufacturer_model)', () => { + it('should resume with concatenated businessKey on UPDATE when condition met', async () => { + const car = createTestCar(undefined, 600); + + await POST('/odata/v4/annotation/ResumeCompositeKey', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/ResumeCompositeKey('${car.ID}')`, { + mileage: 400, // mileage <= 500 + }); + + const resumeMessages = findResumeMessages(); + expect(resumeMessages.length).toBe(1); + expect(resumeMessages[0].data).toEqual({ + businessKey: `${car.manufacturer}_${car.model}`, + cascade: false, + }); + }); + + it('should NOT resume when condition is NOT met', async () => { + const car = createTestCar(undefined, 600); + + await POST('/odata/v4/annotation/ResumeCompositeKey', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/ResumeCompositeKey('${car.ID}')`, { + mileage: 700, // mileage > 500 + }); + + const resumeMessages = findResumeMessages(); + expect(resumeMessages.length).toBe(0); + }); + }); + + // ================================================ + // FULL LIFECYCLE WITH COMPOSITE BUSINESS KEY + // businessKey: (concat(model, concat('/', manufacturer))) + // ================================================ + describe('Full lifecycle with composite businessKey (model/manufacturer)', () => { + it('should start process on CREATE (no businessKey in start context)', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/CompositeKeyLifecycle', car); + + expect(response.status).toBe(201); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe('compositeKeyLifecycleProcess'); + }); + + it('should suspend with composite businessKey on UPDATE', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/CompositeKeyLifecycle', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/CompositeKeyLifecycle('${car.ID}')`, { + mileage: 600, // mileage > 500 + }); + + const suspendMessages = findSuspendMessages(); + expect(suspendMessages.length).toBe(1); + expect(suspendMessages[0].data).toEqual({ + businessKey: `${car.model}/${car.manufacturer}`, + cascade: false, + }); + }); + + it('should resume with composite businessKey on UPDATE', async () => { + const car = createTestCar(undefined, 600); + + await POST('/odata/v4/annotation/CompositeKeyLifecycle', car); + + foundMessages = []; + + await PATCH(`/odata/v4/annotation/CompositeKeyLifecycle('${car.ID}')`, { + mileage: 400, // mileage <= 500 + }); + + const resumeMessages = findResumeMessages(); + expect(resumeMessages.length).toBe(1); + expect(resumeMessages[0].data).toEqual({ + businessKey: `${car.model}/${car.manufacturer}`, + cascade: false, + }); + }); + + it('should cancel with composite businessKey on DELETE', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/CompositeKeyLifecycle', car); + + foundMessages = []; + + await DELETE(`/odata/v4/annotation/CompositeKeyLifecycle('${car.ID}')`); + + const cancelMessages = findCancelMessages(); + expect(cancelMessages.length).toBe(1); + expect(cancelMessages[0].data).toEqual({ + businessKey: `${car.model}/${car.manufacturer}`, + cascade: true, + }); + }); + }); +}); diff --git a/tests/integration/annotations/combinations.test.ts b/tests/integration/annotations/combinations.test.ts index 469cade..96395b7 100644 --- a/tests/integration/annotations/combinations.test.ts +++ b/tests/integration/annotations/combinations.test.ts @@ -57,7 +57,6 @@ describe('Integration tests for Process Annotation Combinations', () => { const startMessages = findStartMessages(); expect(startMessages.length).toBe(1); expect(startMessages[0].data.definitionId).toBe('basicLifecycleProcess'); - expect(startMessages[0].data.context.businesskey).toBe(car.ID); }); it('should cancel process on DELETE', async () => { diff --git a/tests/integration/annotations/customEvents.test.ts b/tests/integration/annotations/customEvents.test.ts index ec3b1ce..1fb1c09 100644 --- a/tests/integration/annotations/customEvents.test.ts +++ b/tests/integration/annotations/customEvents.test.ts @@ -56,7 +56,6 @@ describe('Integration tests for Process Annotations with Custom Events (Bound Ac expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); @@ -80,7 +79,6 @@ describe('Integration tests for Process Annotations with Custom Events (Bound Ac expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); diff --git a/tests/integration/annotations/deleteMultiEvent.test.ts b/tests/integration/annotations/deleteMultiEvent.test.ts index 4e4b387..93e9403 100644 --- a/tests/integration/annotations/deleteMultiEvent.test.ts +++ b/tests/integration/annotations/deleteMultiEvent.test.ts @@ -55,7 +55,6 @@ describe('Integration tests for multiple process events on DELETE', () => { expect(foundMessages.length).toBe(2); expect(findStartMessages().length).toBe(1); expect(findStartMessages()[0].data.definitionId).toBe('deleteStartCancelProcess'); - expect(findStartMessages()[0].data.context.businesskey).toBe(car.ID); expect(findCancelMessages().length).toBe(1); expect(findCancelMessages()[0].data).toEqual({ businessKey: car.ID, @@ -185,7 +184,6 @@ describe('Integration tests for multiple process events on DELETE', () => { expect(foundMessages.length).toBe(3); expect(findStartMessages().length).toBe(1); expect(findStartMessages()[0].data.definitionId).toBe('deleteStartCancelResumeProcess'); - expect(findStartMessages()[0].data.context.businesskey).toBe(car.ID); expect(findCancelMessages().length).toBe(1); expect(findCancelMessages()[0].data).toEqual({ businessKey: car.ID, @@ -215,7 +213,6 @@ describe('Integration tests for multiple process events on DELETE', () => { expect(foundMessages.length).toBe(4); expect(findStartMessages().length).toBe(1); expect(findStartMessages()[0].data.definitionId).toBe('deleteAllEventsProcess'); - expect(findStartMessages()[0].data.context.businesskey).toBe(car.ID); expect(findCancelMessages().length).toBe(1); expect(findCancelMessages()[0].data).toEqual({ businessKey: car.ID, @@ -254,7 +251,6 @@ describe('Integration tests for multiple process events on DELETE', () => { const startMsgs = findStartMessages(); expect(startMsgs.length).toBe(1); expect(startMsgs[0].data.definitionId).toBe('deleteStartInputsCancelProcess'); - expect(startMsgs[0].data.context.businesskey).toBe(car.ID); expect(startMsgs[0].data.context.CarModel).toBe('Test Model'); expect(startMsgs[0].data.context.CarMaker).toBe('Test Manufacturer'); diff --git a/tests/integration/annotations/isolated.test.ts b/tests/integration/annotations/isolated.test.ts index ca311ef..a7a3b80 100644 --- a/tests/integration/annotations/isolated.test.ts +++ b/tests/integration/annotations/isolated.test.ts @@ -50,7 +50,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); @@ -67,7 +66,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); @@ -103,7 +101,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnUpdateProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should start process on UPDATE when condition is met', async () => { @@ -125,7 +122,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnUpdateWhenProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should NOT start process on UPDATE when condition is NOT met', async () => { @@ -165,7 +161,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnDeleteProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should start process on DELETE when condition is met', async () => { @@ -184,7 +179,6 @@ describe('Integration tests for Process Annotations (Isolated)', () => { expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnDeleteWhenProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should NOT start process on DELETE when condition is NOT met', async () => { diff --git a/tests/integration/annotations/processStart-input.test.ts b/tests/integration/annotations/processStart-input.test.ts index 2e93adb..fe00788 100644 --- a/tests/integration/annotations/processStart-input.test.ts +++ b/tests/integration/annotations/processStart-input.test.ts @@ -55,7 +55,7 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toBeDefined(); // All fields should be present - expect(context).toEqual({ ...shipment, businesskey: shipment.ID }); + expect(context).toEqual({ ...shipment }); }); }); @@ -88,7 +88,6 @@ describe('Integration tests for START annotation with inputs array', () => { ID: shipment.ID, shipmentDate: shipment.shipmentDate, origin: shipment.origin, - businesskey: shipment.ID, }); }); }); @@ -120,7 +119,6 @@ describe('Integration tests for START annotation with inputs array', () => { // ID should remain as ID (no alias) expect(context).toEqual({ ID: shipment.ID, - businesskey: shipment.ID, ProcessStartDate: shipment.shipmentDate, // alias SourceLocation: shipment.origin, // alias TargetLocation: shipment.destination, // alias @@ -169,8 +167,7 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: order.ID, shipmentDate: order.shipmentDate, - businesskey: order.ID, - items: order.items, // entire composition included with all fields via $self.items + items: order.items, // entire composition included with all fields }); }); }); @@ -213,7 +210,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: order.ID, shipmentDate: order.shipmentDate, - businesskey: order.ID, items: [ // composition included but only with specified fields: ID, title, price { @@ -263,7 +259,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: order.ID, ProcessDate: order.orderDate, // alias - businesskey: order.ID, OrderLines: [ { ID: order.items[0].ID, @@ -309,7 +304,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: shipment.ID, status: shipment.status, - businesskey: shipment.ID, items: [ { ID: shipment.items[0].ID, @@ -393,7 +387,6 @@ describe('Integration tests for START annotation with inputs array', () => { status: order.status, shipmentDate: order.shipmentDate, totalValue: order.totalValue, - businesskey: order.ID, items: [ { ID: order.items[0].ID, @@ -436,7 +429,6 @@ describe('Integration tests for START annotation with inputs array', () => { status: order.status, shipmentDate: order.shipmentDate, totalValue: order.totalValue, - businesskey: order.ID, items: [ { ID: order.items[0].ID, @@ -473,7 +465,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: order.ID, status: order.status, - businesskey: order.ID, items: [ { ID: order.items[0].ID, @@ -519,7 +510,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ OrderId: order.ID, ReferenceId: order.ID, - businesskey: order.ID, }); }); }); @@ -550,7 +540,6 @@ describe('Integration tests for START annotation with inputs array', () => { // items should appear under both aliases: Orders and LineItems expect(context).toEqual({ ID: order.ID, - businesskey: order.ID, Orders: [ { ID: order.items[0].ID, @@ -608,7 +597,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: entity.ID, status: entity.status, - businesskey: entity.ID, author_ID: entity.author_ID, }); }); @@ -645,7 +633,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: entity.ID, status: entity.status, - businesskey: entity.ID, author_ID: author.ID, }); }); @@ -685,7 +672,6 @@ describe('Integration tests for START annotation with inputs array', () => { expect(context).toEqual({ ID: entity.ID, status: entity.status, - businesskey: entity.ID, author_ID: author.ID, author: { ID: author.ID, diff --git a/tests/integration/annotations/wildcardEvents.test.ts b/tests/integration/annotations/wildcardEvents.test.ts index 23620f9..fc72cd2 100644 --- a/tests/integration/annotations/wildcardEvents.test.ts +++ b/tests/integration/annotations/wildcardEvents.test.ts @@ -47,7 +47,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); @@ -68,7 +67,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnWildcardProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should start process on DELETE', async () => { @@ -86,7 +84,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnWildcardProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should start process on bound action', async () => { @@ -106,7 +103,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnWildcardProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); }); @@ -125,7 +121,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages[0].data.context).toBeDefined(); expect(foundMessages[0].data.context).toEqual({ ...car, - businesskey: car.ID, }); }); @@ -155,7 +150,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnWildcardWhenProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should NOT start process on UPDATE when condition is NOT met', async () => { @@ -192,7 +186,6 @@ describe('Integration tests for Process Annotations with Wildcard Event (*)', () expect(foundMessages.length).toBe(1); expect(foundMessages[0].data.definitionId).toBe('startOnWildcardWhenProcess'); expect(foundMessages[0].data.context).toBeDefined(); - expect(foundMessages[0].data.context.businesskey).toBe(car.ID); }); it('should NOT start process on bound action when condition is NOT met', async () => { diff --git a/tests/integration/build-validation/generic.test.ts b/tests/integration/build-validation/generic.test.ts index 8c29894..d558b25 100644 --- a/tests/integration/build-validation/generic.test.ts +++ b/tests/integration/build-validation/generic.test.ts @@ -49,6 +49,7 @@ describe('Build Validation: Required Annotations', () => { it('should PASS when both on and cascade are present', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { on: 'DELETE', cascade: true } + @bpm.process.businessKey: (ID) entity ValidEntity { key ID: UUID; } `); @@ -61,6 +62,7 @@ describe('Build Validation: Required Annotations', () => { it('should PASS when on is present but cascade is missing (cascade defaults to false)', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { on: 'DELETE' } + @bpm.process.businessKey: (ID) entity MissingCascade { key ID: UUID; } `); @@ -111,6 +113,7 @@ describe('.on annotation tests ', () => { annotationBase: string; annotationOn: string; baseProps: string; + businessKeyAnnotation: string; } const annotationConfigs: AnnotationConfig[] = [ @@ -118,40 +121,48 @@ describe('.on annotation tests ', () => { annotationBase: PROCESS_START, annotationOn: PROCESS_START_ON, baseProps: `id: 'someID'`, + businessKeyAnnotation: '', }, { annotationBase: PROCESS_CANCEL, annotationOn: PROCESS_CANCEL_ON, baseProps: 'cascade: true', + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_SUSPEND, annotationOn: PROCESS_SUSPEND_ON, baseProps: 'cascade: true', + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_RESUME, annotationOn: PROCESS_RESUME_ON, baseProps: 'cascade: true', + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, ]; describe.each(annotationConfigs)( 'Build Validation: $annotationOn', - ({ annotationBase, baseProps }) => { + ({ annotationBase, baseProps, businessKeyAnnotation }) => { describe('Valid events', () => { it('should PASS with CRUD events', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: 'CREATE' } + ${businessKeyAnnotation} entity OnCreate { key ID: UUID; } ${annotationBase}: { ${baseProps}, on: 'UPDATE' } + ${businessKeyAnnotation} entity OnUpdate { key ID: UUID; } ${annotationBase}: { ${baseProps}, on: 'DELETE' } + ${businessKeyAnnotation} entity OnDelete { key ID: UUID; } ${annotationBase}: { ${baseProps}, on: 'READ' } + ${businessKeyAnnotation} entity OnRead { key ID: UUID; } `); @@ -163,6 +174,7 @@ describe('.on annotation tests ', () => { it('should PASS with wildcard (*) event', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: '*' } + ${businessKeyAnnotation} entity OnWildcard { key ID: UUID; } `); @@ -174,6 +186,7 @@ describe('.on annotation tests ', () => { it('should PASS when on references a valid bound action', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: 'customAction' } + ${businessKeyAnnotation} entity WithAction { key ID: UUID; status: String; @@ -192,6 +205,7 @@ describe('.on annotation tests ', () => { it('should ERROR when on is not a valid lifecycle event or action', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: 'INVALID_EVENT' } + ${businessKeyAnnotation} entity InvalidEvent { key ID: UUID; } `); @@ -209,6 +223,7 @@ describe('.on annotation tests ', () => { it('should ERROR when on is lowercase (case sensitive)', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: 'delete' } + ${businessKeyAnnotation} entity LowercaseEvent { key ID: UUID; } `); @@ -221,6 +236,7 @@ describe('.on annotation tests ', () => { it('should ERROR when on is mixed case', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: 'Delete' } + ${businessKeyAnnotation} entity MixedCaseEvent { key ID: UUID; } `); @@ -233,6 +249,7 @@ describe('.on annotation tests ', () => { it('should ERROR when on is empty string', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, on: '' } + ${businessKeyAnnotation} entity EmptyOn { key ID: UUID; } `); @@ -280,6 +297,7 @@ describe('.cascade annotation tests', () => { it('should PASS when cascade is true', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, cascade: true } + @bpm.process.businessKey: (ID) entity CascadeTrue { key ID: UUID; } `); @@ -291,6 +309,7 @@ describe('.cascade annotation tests', () => { it('should PASS when cascade is false', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, cascade: false } + @bpm.process.businessKey: (ID) entity CascadeFalse { key ID: UUID; } `); @@ -304,6 +323,7 @@ describe('.cascade annotation tests', () => { it('should ERROR when cascade is a string instead of boolean', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, cascade: 'true' } + @bpm.process.businessKey: (ID) entity CascadeString { key ID: UUID; } `); @@ -321,6 +341,7 @@ describe('.cascade annotation tests', () => { it('should ERROR when cascade is a number instead of boolean', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps}, cascade: 1 } + @bpm.process.businessKey: (ID) entity CascadeNumber { key ID: UUID; } `); @@ -345,6 +366,7 @@ describe('.if annotation tests', () => { annotationBase: string; annotationIf: string; baseProps: string; + businessKeyAnnotation: string; } const annotationConfigs: AnnotationConfig[] = [ @@ -352,27 +374,31 @@ describe('.if annotation tests', () => { annotationBase: PROCESS_START, annotationIf: PROCESS_START_IF, baseProps: `id: 'someProcess', on: 'UPDATE'`, + businessKeyAnnotation: '', }, { annotationBase: PROCESS_CANCEL, annotationIf: PROCESS_CANCEL_IF, baseProps: `on: 'UPDATE', cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_RESUME, annotationIf: PROCESS_RESUME_IF, baseProps: `on: 'UPDATE', cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_SUSPEND, annotationIf: PROCESS_SUSPEND_IF, baseProps: `on: 'UPDATE', cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, ]; describe.each(annotationConfigs)( 'Build Validation: $annotationIf', - ({ annotationBase, annotationIf, baseProps }) => { + ({ annotationBase, annotationIf, baseProps, businessKeyAnnotation }) => { describe('Valid expressions', () => { it('should PASS with simple comparison expression', async () => { const cdsSource = wrapEntity(` @@ -380,6 +406,7 @@ describe('.if annotation tests', () => { ${baseProps}, if: (status = 'CANCELLED') } + ${businessKeyAnnotation} entity SimpleWhen { key ID: UUID; status: String; @@ -397,6 +424,7 @@ describe('.if annotation tests', () => { ${baseProps}, if: (status = 'CANCELLED' and priority != 'HIGH') } + ${businessKeyAnnotation} entity ComplexAndWhen { key ID: UUID; status: String; @@ -415,6 +443,7 @@ describe('.if annotation tests', () => { ${baseProps}, if: (retryCount >= 3) } + ${businessKeyAnnotation} entity NumericWhen { key ID: UUID; retryCount: Integer; @@ -434,6 +463,7 @@ describe('.if annotation tests', () => { ${baseProps}, if: 'not an expression' } + ${businessKeyAnnotation} entity InvalidWhen { key ID: UUID; retryCount: Integer; @@ -461,6 +491,7 @@ describe('other validation logic tests', () => { annotationBase: string; baseProps: string; invalidProps: string; + businessKeyAnnotation: string; } const annotationConfigs: AnnotationConfig[] = [ @@ -468,32 +499,37 @@ describe('other validation logic tests', () => { annotationBase: PROCESS_START, baseProps: `id: 'someProcess', on: 'DELETE'`, invalidProps: `id: 'someProcess'`, + businessKeyAnnotation: '', }, { annotationBase: PROCESS_CANCEL, baseProps: `on: 'DELETE', cascade: true`, invalidProps: `cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_SUSPEND, baseProps: `on: 'DELETE', cascade: true`, invalidProps: `cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, { annotationBase: PROCESS_RESUME, baseProps: `on: 'DELETE', cascade: true`, invalidProps: `cascade: true`, + businessKeyAnnotation: '@bpm.process.businessKey: (ID)', }, ]; describe.each(annotationConfigs)( 'Build Validation: $annotationBase other tests', - ({ annotationBase, baseProps, invalidProps }) => { + ({ annotationBase, baseProps, invalidProps, businessKeyAnnotation }) => { it('should WARN for multiple unknown annotations', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { ${baseProps} } ${annotationBase}.foo: 'bar' ${annotationBase}.baz: 123 + ${businessKeyAnnotation} entity MultipleUnknown { key ID: UUID; } `); @@ -509,9 +545,11 @@ describe('other validation logic tests', () => { const cdsSource = ` service TestService { ${annotationBase}: { ${baseProps} } + ${businessKeyAnnotation} entity ValidEntity1 { key ID: UUID; } ${annotationBase}: { ${baseProps} } + ${businessKeyAnnotation} entity ValidEntity2 { key ID: UUID; } ${annotationBase}: { ${invalidProps} } @@ -530,3 +568,100 @@ describe('other validation logic tests', () => { }, ); }); + +// Tests business key requirement for cancel, suspend, resume +describe('Business key validation tests', () => { + const lifecycleAnnotations = [ + { annotationBase: PROCESS_CANCEL, annotationOn: PROCESS_CANCEL_ON }, + { annotationBase: PROCESS_SUSPEND, annotationOn: PROCESS_SUSPEND_ON }, + { annotationBase: PROCESS_RESUME, annotationOn: PROCESS_RESUME_ON }, + ]; + + describe.each(lifecycleAnnotations)( + 'Business key required for $annotationBase', + ({ annotationBase }) => { + it('should ERROR when .on is defined but no business key annotation exists', async () => { + const cdsSource = wrapEntity(` + ${annotationBase}: { on: 'UPDATE' } + entity NoBizKey { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.msg.includes('business key') && e.msg.includes(annotationBase), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(false); + }); + + it('should ERROR when .on and .cascade are defined but no business key annotation exists', async () => { + const cdsSource = wrapEntity(` + ${annotationBase}: { on: 'DELETE', cascade: true } + entity NoBizKeyCascade { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.msg.includes('business key') && e.msg.includes(annotationBase), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(false); + }); + + it('should PASS with @bpm.process.businessKey expression', async () => { + const cdsSource = wrapEntity(` + ${annotationBase}: { on: 'UPDATE' } + @bpm.process.businessKey: (ID) + entity WithBusinessKey { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should PASS with @bpm.process.businessKey composite expression', async () => { + const cdsSource = wrapEntity(` + ${annotationBase}: { on: 'UPDATE' } + @bpm.process.businessKey: (name || '-' || ID) + entity WithCompositeBusinessKey { key ID: UUID; name: String; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + }, + ); + + it('should NOT require business key for @bpm.process.start', async () => { + const cdsSource = wrapEntity(` + @bpm.process.start: { id: 'someProcess', on: 'CREATE' } + entity StartNoBizKey { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors.some((e) => e.msg.includes('business key'))).toBe(false); + expect(result.buildSucceeded).toBe(true); + }); + + it('should NOT require business key for entities without process annotations', async () => { + const cdsSource = wrapEntity(` + entity PlainEntity { key ID: UUID; name: String; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); +}); diff --git a/types/cds-extensions.d.ts b/types/cds-extensions.d.ts index d12040b..e1193d6 100644 --- a/types/cds-extensions.d.ts +++ b/types/cds-extensions.d.ts @@ -72,6 +72,7 @@ declare module '@sap/cds' { '@bpm.process.resume.on'?: string; '@bpm.process.resume.cascade'?: boolean; '@bpm.process.resume.if'?: object; + '@bpm.process.businessKey'?: object; } interface Results extends cds.ResultSet {