diff --git a/tests/bookshop/srv/programmatic-service.cds b/tests/bookshop/srv/programmatic-service.cds index 397c088..90685fe 100644 --- a/tests/bookshop/srv/programmatic-service.cds +++ b/tests/bookshop/srv/programmatic-service.cds @@ -40,4 +40,14 @@ service ProgrammaticService { action getInstanceIDForGetOutputs(ID: UUID, status: many String) returns many AttributeEntry; + + // Generic ProcessService actions (using cds.connect.to('ProcessService')) + action genericStart(definitionId: String, businessKey: String, context: LargeString); + action genericCancel(businessKey: String, cascade: Boolean); + action genericSuspend(businessKey: String, cascade: Boolean); + action genericResume(businessKey: String, cascade: Boolean); + action genericGetInstancesByBusinessKey(businessKey: String, + status: many String) returns many ProcessInstance; + action genericGetAttributes(processInstanceId: String) returns many ProcessAttribute; + action genericGetOutputs(processInstanceId: String) returns ProcessOutputs; } diff --git a/tests/bookshop/srv/programmatic-service.ts b/tests/bookshop/srv/programmatic-service.ts index 1d0050e..dc0f7a3 100644 --- a/tests/bookshop/srv/programmatic-service.ts +++ b/tests/bookshop/srv/programmatic-service.ts @@ -110,6 +110,64 @@ class ProgrammaticService extends cds.ApplicationService { return outputs; }); + // Generic ProcessService handlers (using cds.connect.to('ProcessService')) + this.on('genericStart', async (req: cds.Request) => { + const { definitionId, businessKey, context } = req.data; + const processService = await cds.connect.to('ProcessService'); + const queuedProcessService = cds.queued(processService); + const parsedContext = context ? JSON.parse(context) : {}; + await queuedProcessService.emit( + 'start', + { definitionId, context: parsedContext }, + { businessKey }, + ); + }); + + this.on('genericCancel', async (req: cds.Request) => { + const { businessKey, cascade } = req.data; + const processService = await cds.connect.to('ProcessService'); + const queuedProcessService = cds.queued(processService); + await queuedProcessService.emit('cancel', { businessKey, cascade: cascade ?? false }); + }); + + this.on('genericSuspend', async (req: cds.Request) => { + const { businessKey, cascade } = req.data; + const processService = await cds.connect.to('ProcessService'); + const queuedProcessService = cds.queued(processService); + await queuedProcessService.emit('suspend', { businessKey, cascade: cascade ?? false }); + }); + + this.on('genericResume', async (req: cds.Request) => { + const { businessKey, cascade } = req.data; + const processService = await cds.connect.to('ProcessService'); + const queuedProcessService = cds.queued(processService); + await queuedProcessService.emit('resume', { businessKey, cascade: cascade ?? false }); + }); + + this.on('genericGetInstancesByBusinessKey', async (req: cds.Request) => { + const { businessKey, status } = req.data; + const processService = await cds.connect.to('ProcessService'); + const result = await processService.send('getInstancesByBusinessKey', { + businessKey, + status, + }); + return result; + }); + + this.on('genericGetAttributes', async (req: cds.Request) => { + const { processInstanceId } = req.data; + const processService = await cds.connect.to('ProcessService'); + const result = await processService.send('getAttributes', { processInstanceId }); + return result; + }); + + this.on('genericGetOutputs', async (req: cds.Request) => { + const { processInstanceId } = req.data; + const processService = await cds.connect.to('ProcessService'); + const result = await processService.send('getOutputs', { processInstanceId }); + return result; + }); + await super.init(); } } diff --git a/tests/hybrid/programmaticApproach.test.ts b/tests/hybrid/programmaticApproach.test.ts index 56d623e..2c7fbe0 100644 --- a/tests/hybrid/programmaticApproach.test.ts +++ b/tests/hybrid/programmaticApproach.test.ts @@ -40,11 +40,63 @@ describe('Programmatic Approach Hybrid Tests', () => { ); } + async function startOutputProcess( + ID: string, + mandatory_datetime: string, + mandatory_string: string, + optional_string?: string, + optional_datetime?: string, + ) { + return POST('/odata/v4/programmatic/startForGetOutputs', { + ID, + mandatory_datetime, + mandatory_string, + optional_string, + optional_datetime, + }); + } + + async function getOutputInstances(ID: string, status?: string[]): Promise { + const res = await POST('/odata/v4/programmatic/getInstanceIDForGetOutputs', { ID, status }); + return res.data?.value ?? res.data ?? []; + } + + async function waitForOutputInstances( + ID: string, + status: string[], + expectedCount = 1, + maxRetries = 8, + ): Promise { + for (let i = 0; i < maxRetries; i++) { + const instances = await getOutputInstances(ID, status); + if (instances.length >= expectedCount) return instances; + await new Promise((r) => setTimeout(r, 10000)); + } + throw new Error( + `Timed out waiting for ${expectedCount} output instance(s) with status [${status}] for ID ${ID}`, + ); + } + async function getAttributes(ID: string): Promise { const res = await POST('/odata/v4/programmatic/getAttributes', { ID }); return res.data?.value ?? res.data ?? []; } + async function getOutputs(instanceId: string): Promise { + const res = await POST('/odata/v4/programmatic/getOutputs', { instanceId }); + return res.data; + } + + async function genericGetAttributes(processInstanceId: string): Promise { + const res = await POST('/odata/v4/programmatic/genericGetAttributes', { processInstanceId }); + return res.data?.value ?? res.data ?? []; + } + + async function genericGetOutputs(processInstanceId: string): Promise { + const res = await POST('/odata/v4/programmatic/genericGetOutputs', { processInstanceId }); + return res.data; + } + describe('Process Start', () => { it('should start a process and verify it is RUNNING on SBPA', async () => { const ID = generateID(); @@ -197,48 +249,70 @@ describe('Programmatic Approach Hybrid Tests', () => { }); describe('Get Outputs', () => { - async function startOutputProcess( - ID: string, - mandatory_datetime: string, - mandatory_string: string, - optional_string?: string, - optional_datetime?: string, - ) { - return POST('/odata/v4/programmatic/startForGetOutputs', { + it('should retrieve outputs from a completed process', async () => { + const ID = generateID(); + const mandatory_datetime = new Date().toISOString(); + const mandatory_string = 'test-output-string'; + + await startOutputProcess(ID, mandatory_datetime, mandatory_string); + + const instances = await waitForOutputInstances(ID, ['COMPLETED']); + expect(instances.length).toBe(1); + expect(instances[0]).toHaveProperty('workflowId'); + + const outputs = await getOutputs(instances[0].workflowId); + + expect(outputs).toHaveProperty('mandatory_string'); + expect(outputs).toHaveProperty('mandatory_datetime'); + expect(outputs.mandatory_string).toBeDefined(); + expect(outputs.mandatory_datetime).toBeDefined(); + }); + + it('should return optional fields in outputs when provided', async () => { + const ID = generateID(); + const mandatory_datetime = new Date().toISOString(); + const mandatory_string = 'test-mandatory'; + const optional_string = 'test-optional'; + const optional_datetime = new Date().toISOString(); + + await startOutputProcess( ID, mandatory_datetime, mandatory_string, optional_string, optional_datetime, - }); - } + ); - async function getOutputInstances(ID: string, status?: string[]): Promise { - const res = await POST('/odata/v4/programmatic/getInstanceIDForGetOutputs', { ID, status }); - return res.data?.value ?? res.data ?? []; - } + const instances = await waitForOutputInstances(ID, ['COMPLETED']); + expect(instances.length).toBe(1); - async function waitForOutputInstances( - ID: string, - status: string[], - expectedCount = 1, - maxRetries = 8, - ): Promise { - for (let i = 0; i < maxRetries; i++) { - const instances = await getOutputInstances(ID, status); - if (instances.length >= expectedCount) return instances; - await new Promise((r) => setTimeout(r, 10000)); - } - throw new Error( - `Timed out waiting for ${expectedCount} output instance(s) with status [${status}] for ID ${ID}`, - ); - } + const outputs = await getOutputs(instances[0].workflowId); - async function getOutputs(instanceId: string): Promise { - const res = await POST('/odata/v4/programmatic/getOutputs', { instanceId }); - return res.data; - } + expect(outputs).toHaveProperty('mandatory_string'); + expect(outputs).toHaveProperty('mandatory_datetime'); + expect(outputs).toHaveProperty('optional_string'); + expect(outputs).toHaveProperty('optional_datetime'); + }); + }); + + describe('Generic Get Attributes', () => { + it('should return attributes for a running process', async () => { + const ID = generateID(); + await startProcess(ID); + const instances = await waitForInstances(ID, ['RUNNING']); + expect(instances.length).toBe(1); + + const attributes = await genericGetAttributes(instances[0].id); + + expect(Array.isArray(attributes)).toBe(true); + expect(attributes.length).toBeGreaterThan(0); + expect(attributes[0]).toHaveProperty('id'); + expect(attributes[0]).toHaveProperty('value'); + expect(attributes[0]).toHaveProperty('type'); + }); + }); + describe('Generic Get Outputs', () => { it('should retrieve outputs from a completed process', async () => { const ID = generateID(); const mandatory_datetime = new Date().toISOString(); @@ -250,7 +324,7 @@ describe('Programmatic Approach Hybrid Tests', () => { expect(instances.length).toBe(1); expect(instances[0]).toHaveProperty('workflowId'); - const outputs = await getOutputs(instances[0].workflowId); + const outputs = await genericGetOutputs(instances[0].workflowId); expect(outputs).toHaveProperty('mandatory_string'); expect(outputs).toHaveProperty('mandatory_datetime'); @@ -276,7 +350,7 @@ describe('Programmatic Approach Hybrid Tests', () => { const instances = await waitForOutputInstances(ID, ['COMPLETED']); expect(instances.length).toBe(1); - const outputs = await getOutputs(instances[0].workflowId); + const outputs = await genericGetOutputs(instances[0].workflowId); expect(outputs).toHaveProperty('mandatory_string'); expect(outputs).toHaveProperty('mandatory_datetime'); diff --git a/tests/integration/genericProgrammaticApproach.test.ts b/tests/integration/genericProgrammaticApproach.test.ts new file mode 100644 index 0000000..bbd6e7c --- /dev/null +++ b/tests/integration/genericProgrammaticApproach.test.ts @@ -0,0 +1,222 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cds from '@sap/cds'; +import * as path from 'path'; + +const app = path.join(__dirname, '../bookshop/'); +const { POST } = cds.test(app); + +const DEFINITION_ID = 'eu12.cdsmunich.capprocesspluginhybridtest.programmatic_Lifecycle_Process'; + +describe('Generic ProcessService Integration Tests', () => { + 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 () => { + foundMessages = []; + }); + + afterAll(async () => { + await (cds as any).flush(); + }); + + function generateID(): string { + return cds.utils.uuid(); + } + + async function genericStart(businessKey: string, context?: Record) { + const startContext = context ?? { ID: businessKey }; + return POST('/odata/v4/programmatic/genericStart', { + definitionId: DEFINITION_ID, + businessKey, + context: JSON.stringify(startContext), + }); + } + + async function genericCancel(businessKey: string, cascade = false) { + return POST('/odata/v4/programmatic/genericCancel', { + businessKey, + cascade, + }); + } + + async function genericSuspend(businessKey: string, cascade = false) { + return POST('/odata/v4/programmatic/genericSuspend', { + businessKey, + cascade, + }); + } + + async function genericResume(businessKey: string, cascade = false) { + return POST('/odata/v4/programmatic/genericResume', { + businessKey, + cascade, + }); + } + + describe('Process Start Event', () => { + it('should emit a start event to the outbox', async () => { + const businessKey = generateID(); + const response = await genericStart(businessKey); + + expect(response.status).toBe(204); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + }); + + it('should include definitionId in the start event payload', async () => { + const businessKey = generateID(); + await genericStart(businessKey); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.definitionId).toBe(DEFINITION_ID); + }); + + it('should include the businessKey as context ID in the start event', async () => { + const businessKey = generateID(); + await genericStart(businessKey); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.context).toBeDefined(); + expect(foundMessages[0].data.context.ID).toEqual(businessKey); + }); + + it('should emit separate start events for multiple processes', async () => { + const keyA = generateID(); + const keyB = generateID(); + + await genericStart(keyA); + await genericStart(keyB); + + expect(foundMessages.length).toBe(2); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[1].event).toBe('start'); + expect(foundMessages[0].data.context.ID).toEqual(keyA); + expect(foundMessages[1].data.context.ID).toEqual(keyB); + }); + }); + + describe('Process Cancel Event', () => { + it('should emit a cancel event to the outbox', async () => { + const businessKey = generateID(); + const response = await genericCancel(businessKey); + + expect(response.status).toBe(204); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('cancel'); + expect(foundMessages[0].data.businessKey).toEqual(businessKey); + }); + + it('should include cascade=false by default in cancel payload', async () => { + const businessKey = generateID(); + await genericCancel(businessKey); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.cascade).toBe(false); + }); + }); + + describe('Process Suspend Event', () => { + it('should emit a suspend event to the outbox', async () => { + const businessKey = generateID(); + const response = await genericSuspend(businessKey); + + expect(response.status).toBe(204); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('suspend'); + expect(foundMessages[0].data.businessKey).toEqual(businessKey); + }); + + it('should include cascade=false by default in suspend payload', async () => { + const businessKey = generateID(); + await genericSuspend(businessKey); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.cascade).toBe(false); + }); + }); + + describe('Process Resume Event', () => { + it('should emit a resume event to the outbox', async () => { + const businessKey = generateID(); + const response = await genericResume(businessKey); + + expect(response.status).toBe(204); + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('resume'); + expect(foundMessages[0].data.businessKey).toEqual(businessKey); + }); + + it('should include cascade=false by default in resume payload', async () => { + const businessKey = generateID(); + await genericResume(businessKey); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].data.cascade).toBe(false); + }); + }); + + describe('Sequential lifecycle operations', () => { + it('should emit start, suspend, and resume events in order', async () => { + const businessKey = generateID(); + + await genericStart(businessKey); + await genericSuspend(businessKey); + await genericResume(businessKey); + + expect(foundMessages.length).toBe(3); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[1].event).toBe('suspend'); + expect(foundMessages[2].event).toBe('resume'); + }); + + it('should emit start then cancel events in order', async () => { + const businessKey = generateID(); + + await genericStart(businessKey); + await genericCancel(businessKey); + + expect(foundMessages.length).toBe(2); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[1].event).toBe('cancel'); + expect(foundMessages[0].data.context.ID).toEqual(businessKey); + expect(foundMessages[1].data.businessKey).toEqual(businessKey); + }); + + it('should emit start, suspend, resume, and cancel events in order', async () => { + const businessKey = generateID(); + + await genericStart(businessKey); + await genericSuspend(businessKey); + await genericResume(businessKey); + await genericCancel(businessKey); + + expect(foundMessages.length).toBe(4); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[1].event).toBe('suspend'); + expect(foundMessages[2].event).toBe('resume'); + expect(foundMessages[3].event).toBe('cancel'); + }); + }); + + describe('Custom context in start event', () => { + it('should pass custom context through to the outbox message', async () => { + const businessKey = generateID(); + const customContext = { ID: businessKey, customField: 'customValue', number: 42 }; + + await genericStart(businessKey, customContext); + + expect(foundMessages.length).toBe(1); + expect(foundMessages[0].event).toBe('start'); + expect(foundMessages[0].data.context.customField).toEqual('customValue'); + expect(foundMessages[0].data.context.number).toEqual(42); + }); + }); +});