diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc2d4a..f2450bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Allowing the import of downloaded process models - Support for multiple start annotations on the same entity using qualifier - Support for multiple resume, cancel, and suspend annotations on the same entity using qualifier +- Support for multiple businessKey annotations on the same entity using qualifier ## Version 0.1.1 - 2026-03-27 diff --git a/README.md b/README.md index 8e2510a..eb57a3b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ CAP Plugin to interact with SAP Build Process Automation to manage processes. - [Multiple Start Annotations](#multiple-start-annotations) - [Cancelling, Resuming, or Suspending a Process](#cancelling-resuming-or-suspending-a-process) - [Multiple Cancel/Resume/Suspend Annotations](#multiple-cancelresumesuspend-annotations) + - [Multiple Business Key Annotations](#multiple-business-key-annotations) - [Conditional Execution](#conditional-execution) - [Input Mapping](#input-mapping) - [Programmatic Approach](#programmatic-approach) @@ -135,7 +136,7 @@ Both processes are started when a `CREATE` event occurs on the entity, but `noti - `@bpm.process.` -- Cancel/Suspend/Resume any processes with the given businessKey - `@bpm.process..on` - `@bpm.process..cascade` -- Boolean (optional, defaults to false) -- For cancelling, resuming, or suspending, it is required to have a business key expression annotated on the entity using `@bpm.process.businessKey`. If no business key is annotated, the request will be rejected. +- For cancelling, resuming, or suspending, it is required to have a business key expression annotated on the entity using `@bpm.process.businessKey`. If no business key is annotated, the request will be rejected. When using qualified annotations, a qualified business key (e.g. `@bpm.process.businessKey #one`) is resolved first; if not found, the unqualified `@bpm.process.businessKey` is used as a fallback. - Example: `@bpm.process.businessKey: (id || '-' || name)` Example: @@ -181,6 +182,52 @@ service MyService { } ``` +#### Multiple Business Key Annotations + +When using multiple qualified annotations on the same entity, you can define different business key expressions per qualifier using `@bpm.process.businessKey #qualifier`. Each qualified process annotation first looks for a business key with a matching qualifier. If none is found, the unqualified `@bpm.process.businessKey` is used as a fallback. + +```cds +service MyService { + + @bpm.process.cancel #cancelByName : { + on: 'DELETE', + } + @bpm.process.cancel #cancelById : { + on: 'UPDATE', + } + @bpm.process.businessKey #cancelByName : (name) + @bpm.process.businessKey #cancelById : (ID) + entity MyEntity { + key ID : UUID; + name : String; + }; + +} +``` + +In this example, `@bpm.process.cancel #cancelByName` uses `name` as business key, while `@bpm.process.cancel #cancelById` uses `ID`. + +You can also mix qualified and unqualified business keys. Qualified annotations without a matching qualified business key fall back to the unqualified one: + +```cds +service MyService { + + @bpm.process.cancel #one : { on: 'DELETE' } + @bpm.process.cancel #two : { on: 'UPDATE' } + @bpm.process.businessKey #one : (name) + @bpm.process.businessKey : (ID) + entity MyEntity { + key ID : UUID; + name : String; + }; + +} +``` + +Here, `#one` uses `name` as its business key, while `#two` falls back to the unqualified `@bpm.process.businessKey` and uses `ID`. + +This also applies to `@bpm.process.start`, `@bpm.process.suspend`, and `@bpm.process.resume` annotations. + ### Conditional Execution The `.if` annotation is available on all process operations (`start`, `cancel`, `suspend`, `resume`). It accepts a CDS expression and ensures the operation is only triggered when the expression evaluates to true. @@ -661,6 +708,8 @@ Validation occurs during `cds build` and produces **errors** (hard failures that - Unknown annotations under `@bpm.process.start.*` trigger a warning listing allowed annotations - If no imported process definition is found for the given `id`, a warning is issued as input validation is skipped +- If `@bpm.process.businessKey` is not defined on an entity with a valid start annotation (`id` and `on`), a warning is issued and the business key length check is skipped +- If `@bpm.process.businessKey` is defined but is not a valid CDS expression, a warning is issued and the business key length check is skipped #### Input Validation (when process definition is found) @@ -686,7 +735,7 @@ 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 business key expression must be defined using `@bpm.process.businessKey`. +- If any annotation with `@bpm.process.` is defined, a valid business key expression must be defined using `@bpm.process.businessKey`. When using qualified annotations (e.g. `@bpm.process.cancel #one`), the validation first checks for a matching qualified business key (`@bpm.process.businessKey #one`), then falls back to the unqualified `@bpm.process.businessKey`. - Example: `@bpm.process.businessKey: (id || '-' || name)` would concatenate `id` and `name` with a `-` separator as a business key. - The business key definition must match the one configured in the SBPA Process Builder. diff --git a/lib/build/constants.ts b/lib/build/constants.ts index aa9d636..5eb44b7 100644 --- a/lib/build/constants.ts +++ b/lib/build/constants.ts @@ -39,6 +39,20 @@ export const ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION = ( return `${entityName}: ${annotationBKey} must be a valid expression`; }; +export const WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION = ( + entityName: string, + annotationBKey: string, +): string => { + return `${entityName}: ${annotationBKey} must be a valid expression. Length check will be skipped.`; +}; + +export const WARNING_BUSINESS_KEY_NOT_FOUND = ( + entityName: string, + annotationBKey: string, +): string => { + return `${entityName}: ${annotationBKey} not found for process start. Length check will be skipped.`; +}; + // ============================================================================= // Start Annotation Validation Messages // ============================================================================= diff --git a/lib/build/plugin.ts b/lib/build/plugin.ts index b92906a..6c6f0fe 100644 --- a/lib/build/plugin.ts +++ b/lib/build/plugin.ts @@ -10,6 +10,7 @@ import { validateRequiredStartAnnotations, validateIfAnnotation, validateBusinessKeyAnnotation, + validateBusinessKeyForProcessStart, } from './index'; import { PROCESS_START, @@ -21,11 +22,14 @@ import { SUFFIX_ON, SUFFIX_IF, SUFFIX_INPUTS, - BUSINESS_KEY, SUFFIX_CASCADE, } from '../constants'; import { CsnDefinition, CsnEntity } from '../types/csn-extensions'; -import { getAnnotationPrefixes } from '../shared/annotations-helper'; +import { + extractQualifier, + getAnnotationPrefixes, + resolveBusinessKeyAnnotation, +} from '../shared/annotations-helper'; const LOG = cds.log('process-build'); @@ -101,6 +105,9 @@ export class ProcessValidationPlugin extends BuildPluginBase { const hasOn = def[annotationOn] !== undefined; const hasIf = def[annotationIf] !== undefined; + const qualifier = extractQualifier(prefix, PROCESS_START); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, qualifier); + // required fields validateRequiredStartAnnotations(hasOn, hasId, entityName, annotationOn, annotationId, this); @@ -118,6 +125,10 @@ export class ProcessValidationPlugin extends BuildPluginBase { validateIfAnnotation(def, entityName, annotationIf, this); } + if (hasId && hasOn) { + validateBusinessKeyForProcessStart(def, entityName, businessKeyAnnotation, this); + } + if (hasId && hasOn && processDef) { const processInputs = allDefinitions[`${processDef.name}.ProcessInputs`]; if (typeof processInputs !== 'undefined') { @@ -155,7 +166,10 @@ 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 qualifier = extractQualifier(prefix, annotationBase); + const businessKeyAnnotation = resolveBusinessKeyAnnotation(def, qualifier); + const hasBusinessKey = def[businessKeyAnnotation] !== undefined; // required fields - .on is required if any annotation with this prefix is defined validateRequiredGenericAnnotations( @@ -180,7 +194,7 @@ export class ProcessValidationPlugin extends BuildPluginBase { } if (hasOn && hasBusinessKey) { - validateBusinessKeyAnnotation(def, entityName, this); + validateBusinessKeyAnnotation(def, entityName, businessKeyAnnotation, this); } } } diff --git a/lib/build/validations.ts b/lib/build/validations.ts index efa7f49..b1c0507 100644 --- a/lib/build/validations.ts +++ b/lib/build/validations.ts @@ -1,7 +1,6 @@ import cds from '@sap/cds'; import { ProcessValidationPlugin } from './plugin'; import { CsnDefinition, CsnElement, CsnEntity } from '../types/csn-extensions'; -import { BUSINESS_KEY } from '../constants'; import { createCsnEntityContext, ElementType, @@ -28,6 +27,8 @@ import { WARNING_NO_PROCESS_DEFINITION, WARNING_INPUT_PATH_NOT_IN_ENTITY, ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION, + WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION, + WARNING_BUSINESS_KEY_NOT_FOUND, } from './constants'; import { EntityContext, ParsedInputEntry } from '../shared/input-parser'; @@ -84,14 +85,41 @@ export function validateIfAnnotation( buildPlugin.pushMessage(ERROR_IF_MUST_BE_EXPRESSION(entityName, annotationIf), ERROR); } } +export function validateBusinessKeyForProcessStart( + def: CsnEntity, + entityName: string, + businessKeyAnnotation: `@${string}`, + buildPlugin: ProcessValidationPlugin, +) { + const bKeyExpr = def[businessKeyAnnotation]; + if (!bKeyExpr) { + buildPlugin.pushMessage( + WARNING_BUSINESS_KEY_NOT_FOUND(entityName, businessKeyAnnotation), + WARNING, + ); + return; + } + if (!bKeyExpr['='] || (!bKeyExpr['xpr'] && !bKeyExpr['ref'])) { + buildPlugin.pushMessage( + WARNING_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, businessKeyAnnotation), + WARNING, + ); + return; + } +} + export function validateBusinessKeyAnnotation( def: CsnEntity, entityName: string, + businessKeyAnnotation: `@${string}`, buildPlugin: ProcessValidationPlugin, ) { - const bKeyExpr = def[BUSINESS_KEY]; + const bKeyExpr = def[businessKeyAnnotation]; if (!bKeyExpr || !bKeyExpr['='] || (!bKeyExpr['xpr'] && !bKeyExpr['ref'])) { - buildPlugin.pushMessage(ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, BUSINESS_KEY), ERROR); + buildPlugin.pushMessage( + ERROR_BUSINESS_KEY_MUST_BE_EXPRESSION(entityName, businessKeyAnnotation), + ERROR, + ); } } diff --git a/lib/shared/annotations-helper.ts b/lib/shared/annotations-helper.ts index 78f401e..f99f92e 100644 --- a/lib/shared/annotations-helper.ts +++ b/lib/shared/annotations-helper.ts @@ -18,12 +18,30 @@ import { InputCSNEntry } from './input-parser'; * Returns undefined if the prefix has no qualifier (equals the base) or if the * separator is not the expected '#' character. */ -function extractQualifier(prefix: string, annotationBase: string): string | undefined { +export function extractQualifier(prefix: string, annotationBase: string): string | undefined { if (prefix.length <= annotationBase.length) return undefined; const remainder = prefix.substring(annotationBase.length); return remainder.startsWith('#') ? remainder.substring(1) : undefined; } +/** + * Resolves the business key annotation key for a given qualifier. + * First checks for a qualified businessKey matching the qualifier + * (e.g. '@bpm.process.businessKey#one' for qualifier 'one'), + * then falls back to the unqualified '@bpm.process.businessKey'. + * Returns the annotation key that exists on the entity, or the unqualified key if neither exists. + */ +export function resolveBusinessKeyAnnotation( + entity: cds.entity | CsnEntity, + qualifier: string | undefined, +): `@${string}` { + if (qualifier) { + const qualifiedKey: `@${string}` = `${BUSINESS_KEY}#${qualifier}`; + if ((entity as CsnEntity)[qualifiedKey] !== undefined) return qualifiedKey; + } + return BUSINESS_KEY; +} + /** * Scans all keys on a CDS entity object and returns the unique annotation prefixes * that match the given base annotation. @@ -56,7 +74,8 @@ export function findStartAnnotations(entity: cds.entity): StartAnnotationDescrip const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; const inputs = entity[`${prefix}${SUFFIX_INPUTS}`] as InputCSNEntry[] | undefined; - const businessKey = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; + const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier); + const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['=']; results.push({ qualifier, @@ -88,7 +107,8 @@ export function findLifecycleAnnotations( const cascade = (entity[`${prefix}${SUFFIX_CASCADE}`] as boolean) ?? false; const ifAnnotation = entity[`${prefix}${SUFFIX_IF}`] as { xpr: expr } | undefined; - const businessKey = (entity[`${BUSINESS_KEY}`] as { '=': string } | undefined)?.['=']; + const businessKeyAnnotation = resolveBusinessKeyAnnotation(entity, qualifier); + const businessKey = (entity[businessKeyAnnotation] as { '=': string } | undefined)?.['=']; results.push({ qualifier, diff --git a/tests/bookshop/srv/annotation-hybrid-service.cds b/tests/bookshop/srv/annotation-hybrid-service.cds index 0660d6d..861fbbc 100644 --- a/tests/bookshop/srv/annotation-hybrid-service.cds +++ b/tests/bookshop/srv/annotation-hybrid-service.cds @@ -32,19 +32,37 @@ service AnnotationHybridService { } // Two process starts on create + // Process one: cancel on delete, with business Key = ID + // Process two: suspend/resume on update, with business key = model @bpm.process.start #one : { id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process', on: 'CREATE' } + @bpm.process.cancel #one: { + on: 'DELETE' + } + // need to set model as input field "id" because process is designed to use input id as businessKey @bpm.process.start #two : { id: 'eu12.cdsmunich.capprocesspluginhybridtest.annotation_Lifecycle_Process_Two', on: 'CREATE', inputs: [ - { path: $self.ID, as: 'id'} + { path: $self.model, as: 'id'} ] } - @bpm.process.businessKey: (ID) - entity TwoProcessStarts { + @bpm.process.suspend #two: { + on: 'UPDATE', + if: (mileage < 800) + } + @bpm.process.resume #two: { + on: 'UPDATE', + if: (mileage >= 800) + } + @bpm.process.cancel #two: { + on: 'DELETE' + } + @bpm.process.businessKey #one: (ID) + @bpm.process.businessKey #two: (model) + entity QualifiedAnnotations { key ID : UUID @mandatory; model : String(100); manufacturer : String(100); @@ -52,7 +70,7 @@ service AnnotationHybridService { year : Integer; } - action getInstancesByBusinessKey(ID: UUID, + action getInstancesByBusinessKey(ID: String, status: many String) returns many ProcessInstance; } diff --git a/tests/bookshop/srv/annotation-service.cds b/tests/bookshop/srv/annotation-service.cds index 4011106..d21ad0c 100644 --- a/tests/bookshop/srv/annotation-service.cds +++ b/tests/bookshop/srv/annotation-service.cds @@ -1725,6 +1725,25 @@ service AnnotationService { year } + // Two start annotations on CREATE with different qualified business keys + @bpm.process.start #one : { + id: 'multiStartBkProcess1', + on: 'CREATE', + } + @bpm.process.start #two : { + id: 'multiStartBkProcess2', + on: 'CREATE', + } + @bpm.process.businessKey #one : (ID) + @bpm.process.businessKey #two: (model || '-' || manufacturer) + entity MultiStartDiffBusinessKeys as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } // Two start annotations on CREATE, qualified one has an if condition @bpm.process.start : { id: 'multiStartIfProcess1', @@ -1825,6 +1844,26 @@ service AnnotationService { year } + // Two cancel annotations on DELETE with different qualified business keys + @bpm.process.cancel #one : { + on : 'DELETE', + cascade: true, + } + @bpm.process.cancel #two : { + on : 'DELETE', + cascade: false, + } + @bpm.process.businessKey #one : (ID) + @bpm.process.businessKey #two : (model || '-' || manufacturer) + entity MultiCancelDiffBusinessKeys as + projection on my.Car { + ID, + model, + manufacturer, + mileage, + year + } + // Two cancel annotations on DELETE, qualified one has an if condition @bpm.process.cancel #one : { on : 'DELETE', diff --git a/tests/hybrid/annotationApproach.test.ts b/tests/hybrid/annotationApproach.test.ts index 9c8a154..5ce1a35 100644 --- a/tests/hybrid/annotationApproach.test.ts +++ b/tests/hybrid/annotationApproach.test.ts @@ -89,19 +89,55 @@ describe('Annotation Approach Hybrid Tests', () => { it('should start two processes on create', async () => { const ID = generateID(); - - // CREATE triggers start - await POST('/odata/v4/annotation-hybrid/TwoProcessStarts', { + // model name should be unique to avoid conflicts with other tests since process Two uses model as businessKey + const mockModel = `${ID}-Test Model`; + const mock = { ID, - model: 'Test Model', + model: mockModel, manufacturer: 'Test Manufacturer', mileage: 100, year: 2020, - }); + }; - const runningInstances = await waitForInstances(ID, ['RUNNING']); - expect(runningInstances.length).toBe(2); - expect(runningInstances[0]).toHaveProperty('status', 'RUNNING'); - expect(runningInstances[1]).toHaveProperty('status', 'RUNNING'); + // CREATE triggers start + await POST('/odata/v4/annotation-hybrid/QualifiedAnnotations', mock); + + let runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const runningInstancesTwo = await waitForInstances(mock.model, ['RUNNING']); + expect(runningInstancesTwo.length).toBe(1); + expect(runningInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); + + // UPDATE mileage < 800 should suspend process Two + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`, { mileage: 200 }); + runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const suspendedInstancesTwo = await waitForInstances(mock.model, ['SUSPENDED']); + expect(suspendedInstancesTwo.length).toBe(1); + expect(suspendedInstancesTwo[0]).toHaveProperty('status', 'SUSPENDED'); + + // UPDATE mileage >= 800 should resume process Two + await PATCH(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`, { mileage: 900 }); + runningInstancesOne = await waitForInstances(mock.ID, ['RUNNING']); + expect(runningInstancesOne.length).toBe(1); + expect(runningInstancesOne[0]).toHaveProperty('status', 'RUNNING'); + + const resumedInstancesTwo = await waitForInstances(mock.model, ['RUNNING']); + expect(resumedInstancesTwo.length).toBe(1); + expect(resumedInstancesTwo[0]).toHaveProperty('status', 'RUNNING'); + + // DELETE should cancel both processes + await DELETE(`/odata/v4/annotation-hybrid/QualifiedAnnotations('${mock.ID}')`); + const cancelledInstancesOne = await waitForInstances(mock.ID, ['CANCELED']); + expect(cancelledInstancesOne.length).toBe(1); + expect(cancelledInstancesOne[0]).toHaveProperty('status', 'CANCELED'); + + const cancelledInstancesTwo = await waitForInstances(mock.model, ['CANCELED']); + expect(cancelledInstancesTwo.length).toBe(1); + expect(cancelledInstancesTwo[0]).toHaveProperty('status', 'CANCELED'); }); }); diff --git a/tests/integration/annotations/multipleLifecycleAnnotations.test.ts b/tests/integration/annotations/multipleLifecycleAnnotations.test.ts index 895483a..259cfe1 100644 --- a/tests/integration/annotations/multipleLifecycleAnnotations.test.ts +++ b/tests/integration/annotations/multipleLifecycleAnnotations.test.ts @@ -168,6 +168,37 @@ describe('Integration tests for multiple lifecycle annotations (cancel/suspend/r }); }); }); + // ================================================ + // Two cancels on DELETE with different business keys + // ================================================ + describe('Two cancels on DELETE with different business keys', () => { + it('should trigger both cancels with their respective business keys', async () => { + const car = createTestCar(); + + await POST('/odata/v4/annotation/MultiCancelDiffBusinessKeys', car); + foundMessages = []; + + const response = await DELETE( + `/odata/v4/annotation/MultiCancelDiffBusinessKeys('${car.ID}')`, + ); + + expect(response.status).toBe(204); + + const cancelMsgs = findCancelMessages(); + expect(cancelMsgs.length).toBe(2); + + // Unqualified cancel: @bpm.process.businessKey: (ID), cascade: true + const msg1 = cancelMsgs.find((m: any) => m.data.cascade === true); + // Qualified cancel #two: @bpm.process.businessKey#two: (model || '-' || manufacturer), cascade: false + const msg2 = cancelMsgs.find((m: any) => m.data.cascade === false); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + expect(msg1!.data.businessKey).toBe(car.ID); + expect(msg2!.data.businessKey).toBe(`${car.model}-${car.manufacturer}`); + }); + }); // ================================================ // Two cancels on DELETE with condition diff --git a/tests/integration/annotations/multipleStartAnnotations.test.ts b/tests/integration/annotations/multipleStartAnnotations.test.ts index d69e243..96df096 100644 --- a/tests/integration/annotations/multipleStartAnnotations.test.ts +++ b/tests/integration/annotations/multipleStartAnnotations.test.ts @@ -110,6 +110,34 @@ describe('Integration tests for multiple @bpm.process.start annotations', () => expect(definitionIds).toEqual(['multiStartDeleteProcess1', 'multiStartDeleteProcess2']); }); }); + // ================================================ + // Two starts on CREATE with different business keys + // ================================================ + describe('Two starts on CREATE with different business keys', () => { + it('should trigger both starts with their respective business keys', async () => { + const car = createTestCar(); + + const response = await POST('/odata/v4/annotation/MultiStartDiffBusinessKeys', car); + + expect(response.status).toBe(201); + + const startMsgs = findStartMessages(); + expect(startMsgs.length).toBe(2); + + // Find each start message by definitionId + const msg1 = startMsgs.find((m: any) => m.data.definitionId === 'multiStartBkProcess1'); + const msg2 = startMsgs.find((m: any) => m.data.definitionId === 'multiStartBkProcess2'); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + // Unqualified start: @bpm.process.businessKey: (ID) + expect(msg1!.headers.businessKey).toBe(car.ID); + + // Qualified start #two: @bpm.process.businessKey#two: (model || '-' || manufacturer) + expect(msg2!.headers.businessKey).toBe(`${car.model}-${car.manufacturer}`); + }); + }); // ================================================ // Two starts on CREATE with condition diff --git a/tests/integration/build-validation/processStart.test.ts b/tests/integration/build-validation/processStart.test.ts index 3f52699..e93287b 100644 --- a/tests/integration/build-validation/processStart.test.ts +++ b/tests/integration/build-validation/processStart.test.ts @@ -1,4 +1,9 @@ -import { PROCESS_START, PROCESS_START_ID, PROCESS_START_INPUTS } from '../../../lib/constants'; +import { + BUSINESS_KEY, + PROCESS_START, + PROCESS_START_ID, + PROCESS_START_INPUTS, +} from '../../../lib/constants'; import { validateModel, withProcessDefinition, wrapEntity } from './helpers'; // Tests additional annotation validation specific for start annotation @@ -801,4 +806,47 @@ describe(`Build Validation: @bpm.process.start annotations`, () => { ); }); }); + + describe('BusinessKey annotation for process start', () => { + it('should WARN when businessKey annotation is missing on process start entity', async () => { + const cdsSource = wrapEntity(` + ${PROCESS_START}: { id: 'someProcess', on: 'CREATE' } + entity NoBusinessKeyEntity { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.warnings.length).toBeGreaterThan(0); + expect( + result.warnings.some( + (w) => + w.msg.includes(BUSINESS_KEY) && + w.msg.includes('not found for process start') && + w.msg.includes('Length check will be skipped'), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(true); + }); + + it('should WARN when businessKey annotation is not a valid expression', async () => { + const cdsSource = wrapEntity(` + ${PROCESS_START}: { id: 'someProcess', on: 'CREATE' } + ${BUSINESS_KEY}: 'notAnExpression' + entity InvalidBusinessKeyEntity { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.warnings.length).toBeGreaterThan(0); + expect( + result.warnings.some( + (w) => + w.msg.includes(BUSINESS_KEY) && + w.msg.includes('must be a valid expression') && + w.msg.includes('Length check will be skipped'), + ), + ).toBe(true); + expect(result.buildSucceeded).toBe(true); + }); + }); }); diff --git a/tests/integration/build-validation/qualifiedAnnotations.test.ts b/tests/integration/build-validation/qualifiedAnnotations.test.ts index 08edaa7..50c0064 100644 --- a/tests/integration/build-validation/qualifiedAnnotations.test.ts +++ b/tests/integration/build-validation/qualifiedAnnotations.test.ts @@ -160,6 +160,33 @@ describe('Build Validation: Qualified lifecycle annotations', () => { expect(result.buildSucceeded).toBe(true); }); + it('should PASS with qualified annotation using matching qualified businessKey', async () => { + const cdsSource = wrapEntity(` + ${annotationBase} #special: { on: 'UPDATE' } + @bpm.process.businessKey #special: (name) + entity QualifiedBizKey${label} { key ID: UUID; name: String; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + + it('should PASS with qualified annotation falling back to unqualified businessKey', async () => { + // No #myq qualified businessKey, so falls back to unqualified + const cdsSource = wrapEntity(` + ${annotationBase} #myq: { on: 'UPDATE' } + @bpm.process.businessKey: (ID) + entity FallbackBizKey${label} { key ID: UUID; } + `); + + const result = await validateModel(cdsSource); + + expect(result.errors).toHaveLength(0); + expect(result.buildSucceeded).toBe(true); + }); + it('should PASS with mixed unqualified and qualified annotations', async () => { const cdsSource = wrapEntity(` ${annotationBase}: { on: 'DELETE' }