Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
- This project adheres to [Semantic Versioning](https://semver.org/).

## Version 0.1.1 - 2026-03-27

### Fixed

- Resolving of Cloud credentials during `cds import --from process`

## Version 0.1.0 - 2026-03-27

### Added
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,9 @@ The plugin provides two ways to interact with SBPA processes programmatically:
1. **Specific ProcessService** -- Provides a process specific abstraction on the process as a CAP service.
2. **Generic ProcessService** -- Provides a generic abstraction on the [SBPA workflow api](https://api.sap.com/api/SPA_Workflow_Runtime/overview) as a CAP service.

Both approaches work locally (in-memory), in hybrid mode (against a real SBPA instance), and in production.
The approaches work only in hybrid mode (against a real SBPA instance), and in production. For getAttributes and getOutputs, it is currently not possible to get the real attributes as in a running process.
For the lifecycle operations, the generic ProcessService allows you to set a business key in the header, which can then be used to execute the lifecycle operations in the local environment.
The specific ProcessService does not work locally in the current state of the plugin.

### Specific Process Services

Expand Down Expand Up @@ -526,6 +528,13 @@ await processService.emit('start', {
context: { orderId: '12345', amount: 100.0 },
});

// Start a process with local businessKey
await processService.emit('start', {
definitionId: 'eu12.myorg.myproject.myProcess',
context: { orderId: '12345', amount: 100.0 },
{orderId}, // orderId -> "order-12345"
})

// Cancel all running instances for a business key
await processService.emit('cancel', {
businessKey: 'order-12345',
Expand Down Expand Up @@ -599,8 +608,6 @@ When both `@bpm.process.start.id` and `@bpm.process.start.on` are present and th
- Array cardinality mismatches (entity is array but process expects single value or vice versa)
- Mandatory flag mismatches (process input is mandatory but entity attribute is not marked as `@mandatory`)

**Note:** Associations and compositions are recursively validated, and cycles in entity associations are detected and reported as errors.

### Process Cancel/Suspend/Resume

#### Required Annotations (Errors)
Expand Down
4 changes: 2 additions & 2 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export const PROCESS_DEFINITION_ID = '@Process.DefinitionId' as const;

export const LOG_MESSAGES = {
PROCESS_NOT_STARTED: 'Not starting process as start condition(s) are not met.',
NO_PROCESS_INPUTS_DEFINED:
'No process start input annotations defined, fetching entire entity row for process start context.',
PROCESS_INPUTS_FROM_DEFINITION:
'No inputs annotation defined. Filtering entity fields by process definition inputs.',
PROCESS_NOT_SUSPENDED: 'Not suspending process as suspend condition(s) are not met.',
PROCESS_NOT_RESUMED: 'Not resuming process as resume condition(s) are not met.',
PROCESS_NOT_CANCELLED: 'Not canceling process as cancel condition(s) are not met.',
Expand Down
56 changes: 49 additions & 7 deletions lib/handlers/processStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PROCESS_START_INPUTS,
LOG_MESSAGES,
PROCESS_LOGGER_PREFIX,
PROCESS_PREFIX,
BUSINESS_KEY,
BUSINESS_KEY_MAX_LENGTH,
} from './../constants';
Expand Down Expand Up @@ -48,11 +49,11 @@ export function getColumnsForProcessStart(target: Target): (column_expr | string
const startSpecs = initStartSpecs(target);
startSpecs.inputs = parseInputToTree(target);
if (startSpecs.inputs.length === 0) {
LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED);
return ['*'];
} else {
return convertToColumnsExpr(startSpecs.inputs);
LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION);
return resolveColumnsFromProcessDefinition(startSpecs.id!, target);
}

return convertToColumnsExpr(startSpecs.inputs);
}

export async function handleProcessStart(req: cds.Request, data: EntityRow): Promise<void> {
Expand All @@ -66,11 +67,15 @@ export async function handleProcessStart(req: cds.Request, data: EntityRow): Pro
const startSpecs = initStartSpecs(target);
startSpecs.inputs = parseInputToTree(target);

// if startSpecs.input = [] --> no input defined, fetch entire row
// if startSpecs.input = [] --> no input annotation defined, resolve from process definition
let columns: (column_expr | string)[];
if (startSpecs.inputs.length === 0) {
columns = [WILDCARD];
LOG.debug(LOG_MESSAGES.NO_PROCESS_INPUTS_DEFINED);
if (startSpecs.id) {
columns = resolveColumnsFromProcessDefinition(startSpecs.id, target);
} else {
columns = [WILDCARD];
}
LOG.debug(LOG_MESSAGES.PROCESS_INPUTS_FROM_DEFINITION);
} else {
columns = convertToColumnsExpr(startSpecs.inputs);
}
Expand Down Expand Up @@ -189,6 +194,43 @@ function parseInputToTree(target: Target): ProcessStartInput[] {
return buildInputTree(parsedEntries, runtimeContext);
}

function getProcessInputFieldNames(definitionId: string): string[] | undefined {
const definitions = cds.model?.definitions;
if (!definitions) return undefined;
let serviceName: string | undefined;
for (const name in definitions) {
if (Object.hasOwn(definitions, name)) {
const def = definitions[name] as unknown as Record<string, unknown>;
if (def[PROCESS_PREFIX] === definitionId) {
serviceName = name;
break;
}
}
}

if (!serviceName) return undefined;

const processInputsType = definitions[`${serviceName}.ProcessInputs`] as
| { elements?: Record<string, unknown> }
| undefined;

if (!processInputsType?.elements) return undefined;

return Object.keys(processInputsType.elements);
}

function resolveColumnsFromProcessDefinition(
definitionId: string,
target: Target,
): (column_expr | string)[] {
const processFields = getProcessInputFieldNames(definitionId);
if (!processFields) return [WILDCARD];

const entityElements = Object.keys((target as cds.entity).elements ?? {});
const matchingFields = processFields.filter((f) => entityElements.includes(f));
return matchingFields;
}

function convertToColumnsExpr(array: ProcessStartInput[]): (column_expr | string)[] {
const result: (column_expr | string)[] = [];

Expand Down
4 changes: 4 additions & 0 deletions lib/handlers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ async function fetchEntity(
results = {};
}

if (columns.length === 0) {
return {};
}

const keyFields = getKeyFieldsForEntity(request.target as cds.entity);

// build where clause
Expand Down
1 change: 0 additions & 1 deletion lib/processImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ async function createApiClient(): Promise<IProcessApiClient> {
cdsDk.env = cds.env.for('cds');
Object.assign(process.env, await bindingEnv());
cdsDk.env = cds.env.for('cds');
cdsDk.requires = cds.env.requires;
credentials = getServiceCredentials(PROCESS_SERVICE);
} catch (e) {
LOG.debug('Auto-resolve bindings failed:', e);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cap-js/process",
"version": "0.1.0",
"version": "0.1.1",
"description": "CAP Plugin to interact with SAP Build Process Automation to manage processes.",
"main": "cds-plugin.js",
"files": [
Expand Down
15 changes: 15 additions & 0 deletions tests/bookshop/srv/annotation-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,21 @@ service AnnotationService {
name : String(100);
}

// Test 15: No inputs, ProcessInputs exists but zero entity fields match
// Should send empty context {}
// --------------------------------------------
@bpm.process.start: {
id: 'startNoInputProcess',
on: 'CREATE',
}
entity StartNoInputZeroMatch {
key ID : UUID;
shipmentDate : Date;
expectedDelivery : Date;
totalValue : Decimal(15, 2);
notes : String(500);
}

// ============================================
// BUSINESS KEY LENGTH VALIDATION TESTS
// Testing businessKey max length (255 chars) on processStart
Expand Down
35 changes: 35 additions & 0 deletions tests/bookshop/srv/external/startNoInputProcess.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* checksum : test-fixture-start-no-input */

/** Test fixture: Process definition for startNoInputProcess.
* Used to test that when no @bpm.process.start.inputs annotation is defined,
* only entity fields matching ProcessInputs element names are sent. */
@protocol : 'none'
@bpm.process : 'startNoInputProcess'
service StartNoInputProcessService {

type ProcessInputs {
status : String;
origin : String;
};

type ProcessOutputs {};

action start(
inputs : ProcessInputs not null
);

action suspend(
businessKey : String not null,
cascade : Boolean
);

action resume(
businessKey : String not null,
cascade : Boolean
);

action cancel(
businessKey : String not null,
cascade : Boolean
);
};
36 changes: 36 additions & 0 deletions tests/bookshop/srv/external/startNoInputWithAssocProcess.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* checksum : test-fixture-start-no-input-with-assoc */

/** Test fixture: Process definition for startNoInputWithAssocProcess.
* Used to test that when no @bpm.process.start.inputs annotation is defined,
* only entity fields matching ProcessInputs element names are sent. */
@protocol : 'none'
@bpm.process : 'startNoInputWithAssocProcess'
service StartNoInputWithAssocProcessService {

type ProcessInputs {
ID : UUID;
status : String;
author_ID : UUID;
};

type ProcessOutputs {};

action start(
inputs : ProcessInputs not null
);

action suspend(
businessKey : String not null,
cascade : Boolean
);

action resume(
businessKey : String not null,
cascade : Boolean
);

action cancel(
businessKey : String not null,
cascade : Boolean
);
};
59 changes: 53 additions & 6 deletions tests/integration/annotations/processStart-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ describe('Integration tests for START annotation with inputs array', () => {
let foundMessages: any[] = [];

beforeAll(async () => {
// Mock process definition for startNoInputProcess (no external CDS file needed).
// getProcessInputFieldNames() reads cds.model.definitions at request time,
// so injecting here is sufficient for both StartNoInput and StartNoInputZeroMatch.
const defs = cds.model!.definitions as any;
defs['StartNoInputProcessService'] = {
kind: 'service',
'@bpm.process': 'startNoInputProcess',
};
defs['StartNoInputProcessService.ProcessInputs'] = {
kind: 'type',
elements: {
status: { type: 'cds.String' },
origin: { type: 'cds.String' },
},
};

const db = await cds.connect.to('db');
db.before('*', (req) => {
if (req.event === 'CREATE' && req.target?.name === 'cds.outbox.Messages') {
Expand All @@ -33,11 +49,11 @@ describe('Integration tests for START annotation with inputs array', () => {
};

// ================================================
// Test 1: No inputs array specified
// All entity fields should be included in context
// Test 1: No inputs array specified, but ProcessInputs type exists
// Only entity fields matching ProcessInputs should be included
// ================================================
describe('Test 1: No inputs array (all fields included)', () => {
it('should include all entity fields in process context', async () => {
describe('Test 1: No inputs array (filtered by ProcessInputs)', () => {
it('should include only entity fields matching ProcessInputs element names', async () => {
const shipment = {
ID: '550e8400-e29b-41d4-a716-446655440000',
status: 'PENDING',
Expand All @@ -57,8 +73,11 @@ describe('Integration tests for START annotation with inputs array', () => {
const context = getStartContext();
expect(context).toBeDefined();

// All fields should be present
expect(context).toEqual({ ...shipment });
// Only fields matching ProcessInputs (status, origin) should be present
expect(context).toEqual({
status: shipment.status,
origin: shipment.origin,
});
});
});

Expand Down Expand Up @@ -647,4 +666,32 @@ describe('Integration tests for START annotation with inputs array', () => {
});
});
});

// ================================================
// Test 15: No inputs, ProcessInputs exists but zero entity fields match
// Should send empty context {}
// ================================================
describe('Test 15: No inputs, ProcessInputs exists but zero fields match', () => {
it('should send empty context when no entity fields match ProcessInputs', async () => {
const entity = {
ID: '550e8400-e29b-41d4-a716-44665544ff01',
shipmentDate: '2026-01-15',
expectedDelivery: '2026-01-25',
totalValue: 2500.0,
notes: 'Handle with care',
};

const response = await POST('/odata/v4/annotation/StartNoInputZeroMatch', entity);

expect(response.status).toBe(201);
expect(foundMessages.length).toBe(1);

const context = getStartContext();
expect(context).toBeDefined();

// ProcessInputs has {status, origin} but entity has {ID, shipmentDate, expectedDelivery, totalValue, notes}
// No field names match, so context should be empty
expect(context).toEqual({});
});
});
});
Loading