Skip to content

Commit 3b16519

Browse files
committed
Optionally predict next states
1 parent 83cd06b commit 3b16519

File tree

6 files changed

+8709
-5126
lines changed

6 files changed

+8709
-5126
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test:e2e": "jest --config ./test/jest-e2e.json"
2121
},
2222
"dependencies": {
23-
"@letsflow/core": "^1.0.3",
23+
"@letsflow/core": "^1.0.4",
2424
"@nestjs/common": "^10.3.9",
2525
"@nestjs/core": "^10.3.9",
2626
"@nestjs/event-emitter": "^3.0.0",
@@ -29,6 +29,7 @@
2929
"@nestjs/swagger": "^7.3.1",
3030
"ajv": "^8.16.0",
3131
"amqp-connection-manager": "^4.1.14",
32+
"amqplib": "^0.10.5",
3233
"buffer-crc32": "^1.0.0",
3334
"convict": "^6.2.4",
3435
"mongodb": "^6.7.0",

src/config/test.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
{
22
"db": "mongodb://localhost:27017/letsflow-test",
3-
"summeryFields": {
4-
"scenario": ["extraScenarioField"],
5-
"process": ["extraProcessField"]
3+
"scenario": {
4+
"summeryFields": [
5+
"extraScenarioField"
6+
]
7+
},
8+
"process": {
9+
"summeryFields": [
10+
"extraProcessField"
11+
]
612
}
713
}

src/process/process.controller.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
ApiOperation,
99
ApiParam,
1010
ApiProduces,
11+
ApiQuery,
1112
ApiResponse,
1213
ApiTags,
1314
} from '@nestjs/swagger';
1415
import { StartInstructions } from './process.dto';
1516
import { Response } from 'express';
1617
import { Account, ApiKey, ApiPrivilege, AuthApiKey, AuthGuard, AuthUser } from '@/auth';
17-
import { ActionEvent, Process } from '@letsflow/core/process';
18+
import { ActionEvent, instantiate, Process } from '@letsflow/core/process';
1819
import { AuthService } from '@/auth/auth.service';
1920
import { ScenarioService } from '@/scenario/scenario.service';
2021
import { Scenario } from '@letsflow/core/scenario';
@@ -91,21 +92,23 @@ export class ProcessController {
9192

9293
@ApiOperation({ summary: 'Get a process by ID' })
9394
@ApiParam({ name: 'id', description: 'Process ID', format: 'uuid' })
95+
@ApiQuery({ name: 'predict', description: 'Predict next states', type: 'boolean' })
9496
@ApiResponse({ status: 200, description: 'Success' })
9597
@ApiProduces('application/json')
9698
@Get('/:id')
9799
public async get(
98100
@Param('id') id: string,
99101
@AuthUser() user: Account | undefined,
100102
@AuthApiKey() apiKey: ApiKey | undefined,
103+
@Query('predict') addPrediction: boolean | undefined,
101104
@Res() res: Response,
102105
): Promise<void> {
103106
if (!(await this.processes.has(id))) {
104107
res.status(404).send('Process not found');
105108
return;
106109
}
107110

108-
const process = await this.processes.get(id);
111+
let process = await this.processes.get(id);
109112

110113
if (apiKey && !this.isAllowedByApiKey(apiKey, process.scenario)) {
111114
res.status(403).send('Insufficient privileges of API key');
@@ -117,17 +120,22 @@ export class ProcessController {
117120
return;
118121
}
119122

123+
if (addPrediction) {
124+
process = this.processes.predict(process);
125+
}
126+
120127
res.status(200).json(process);
121128
}
122129

123130
@ApiOperation({ summary: 'Start a process' })
124-
@ApiConsumes('application/json')
131+
@ApiQuery({ name: 'predict', description: 'Predict next states', type: 'boolean' })
125132
@ApiHeader({
126133
name: 'As-Actor',
127134
description:
128135
'Specify actor when multiple actors could have performed the action and actor cannot be determined based on the user',
129136
required: false,
130137
})
138+
@ApiConsumes('application/json')
131139
@ApiBody({ required: true, type: StartInstructions })
132140
@ApiResponse({ status: 201, description: 'Created' })
133141
@ApiPrivilege('process:start')
@@ -136,6 +144,7 @@ export class ProcessController {
136144
@Headers('As-Actor') actor: string | undefined,
137145
@AuthUser() user: Account | undefined,
138146
@AuthApiKey() apiKey: ApiKey | undefined,
147+
@Query('predict') addPrediction: boolean | undefined,
139148
@Body() instructions: StartInstructions,
140149
@Res() res: Response,
141150
): Promise<void> {
@@ -153,16 +162,19 @@ export class ProcessController {
153162
return;
154163
}
155164

156-
let process = await this.processes.instantiate(scenario);
165+
let process = instantiate(scenario);
157166

158167
const { key: action, response } =
159168
typeof instructions.action === 'object' ? instructions.action : { key: instructions.action, response: undefined };
160169

161170
process = await this.doStep(process, user, action, actor, response, res);
171+
if (!process) return;
162172

163-
if (process) {
164-
res.status(201).header('Location', `/processes/${process.id}`).json(process);
173+
if (addPrediction) {
174+
process = this.processes.predict(process);
165175
}
176+
177+
res.status(201).header('Location', `/processes/${process.id}`).json(process);
166178
}
167179

168180
@ApiOperation({ summary: 'Step through a process' })
@@ -174,8 +186,9 @@ export class ProcessController {
174186
'Specify actor when multiple actors could have performed the action and actor cannot be determined based on the user',
175187
required: false,
176188
})
189+
@ApiConsumes('application/json')
177190
@ApiBody({ required: true })
178-
@ApiResponse({ status: 204, description: 'No Content' })
191+
@ApiResponse({ status: 200, description: 'Success' })
179192
@ApiPrivilege('process:step')
180193
@Post(':id/:action')
181194
public async step(
@@ -184,6 +197,7 @@ export class ProcessController {
184197
@Headers('As-Actor') actor: string | undefined,
185198
@AuthUser() user: Account | undefined,
186199
@AuthApiKey() apiKey: ApiKey | undefined,
200+
@Query('predict') addPrediction: boolean | undefined,
187201
@Body() body: any,
188202
@Res() res: Response,
189203
): Promise<void> {
@@ -205,10 +219,13 @@ export class ProcessController {
205219
}
206220

207221
process = await this.doStep(process, user, action, actor, body, res);
222+
if (!process) return;
208223

209-
if (process) {
210-
res.status(200).json(process);
224+
if (addPrediction) {
225+
process = this.processes.predict(process);
211226
}
227+
228+
res.status(200).json(process);
212229
}
213230

214231
private async doStep(

src/process/process.service.spec.ts

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,28 @@ import { ScenarioService } from '@/scenario/scenario.service';
77
import { instantiate, InstantiateEvent, step } from '@letsflow/core/process';
88
import { normalize } from '@letsflow/core/scenario';
99
import { uuid } from '@letsflow/core';
10-
import { from as bsonUUID } from 'uuid-mongodb';
10+
import { from as bsonUUID, MUUID } from 'uuid-mongodb';
1111
import { NotifyService } from '@/notify/notify.service';
1212
import { ConfigModule } from '@/common/config/config.module';
1313
import { ScenarioDbService } from '@/scenario/scenario-db/scenario-db.service';
1414
import { EventEmitter2 } from '@nestjs/event-emitter';
1515

16+
function normalizeBsonUUID(process: { _id: MUUID; scenario: MUUID; [_: string]: any }) {
17+
const { _id: id, scenario, ...rest } = process;
18+
19+
return {
20+
_id: id.toString(),
21+
scenario: scenario.toString(),
22+
...rest,
23+
};
24+
}
25+
1626
describe('ProcessService', () => {
1727
let module: TestingModule;
1828
let service: ProcessService;
19-
let collection: Collection;
20-
let scenarios: ScenarioService;
29+
let collection: jest.Mocked<Collection>;
30+
let scenarios: jest.Mocked<ScenarioService>;
31+
let eventEmitter: jest.Mocked<EventEmitter2>;
2132

2233
beforeEach(async () => {
2334
module = await Test.createTestingModule({
@@ -31,8 +42,9 @@ describe('ProcessService', () => {
3142
],
3243
}).compile();
3344

34-
service = module.get<ProcessService>(ProcessService);
35-
scenarios = module.get<ScenarioService>(ScenarioService);
45+
service = module.get(ProcessService);
46+
scenarios = module.get(ScenarioService);
47+
eventEmitter = module.get(EventEmitter2);
3648

3749
collection = {
3850
findOne: jest.fn(),
@@ -173,13 +185,11 @@ describe('ProcessService', () => {
173185
const process = step(instantiate(scenario), 'start');
174186

175187
it('should save a process', async () => {
176-
const replaceOne = jest.spyOn(collection, 'replaceOne').mockResolvedValue({} as any);
177-
178188
await service.save(process);
179189

180-
expect(replaceOne).toHaveBeenCalled();
181-
expect(replaceOne.mock.calls[0][0]._id.toString()).toEqual(process.id);
182-
const stored = replaceOne.mock.calls[0][1];
190+
expect(collection.replaceOne).toHaveBeenCalled();
191+
expect(collection.replaceOne.mock.calls[0][0]._id.toString()).toEqual(process.id);
192+
const stored = collection.replaceOne.mock.calls[0][1];
183193

184194
expect(stored._id.toString()).toEqual(process.id);
185195
expect(stored.scenario.toString()).toEqual(process.scenario.id);
@@ -209,8 +219,6 @@ describe('ProcessService', () => {
209219

210220
it('should instantiate a process', async () => {
211221
jest.spyOn(scenarios, 'get').mockResolvedValue({ id: scenarioId, ...scenario, _disabled: false });
212-
const replaceOne = jest.spyOn(collection, 'replaceOne').mockResolvedValue({} as any);
213-
214222
const process = await service.instantiate(scenario);
215223

216224
expect(process.id).toMatch(
@@ -251,24 +259,23 @@ describe('ProcessService', () => {
251259
describe('has', () => {
252260
it('should return true is the process exists', async () => {
253261
const processId = 'b2d39a1f-88bb-450e-95c5-feeffe95abe6';
254-
const countDocuments = jest.spyOn(collection, 'countDocuments').mockResolvedValue(1);
262+
collection.countDocuments.mockResolvedValue(1);
255263

256264
const exists = await service.has(processId);
257265
expect(exists).toBe(true);
258266

259-
expect(countDocuments).toHaveBeenCalled();
260-
expect(countDocuments.mock.calls[0][0]._id.toString()).toEqual(processId);
267+
expect(collection.countDocuments).toHaveBeenCalled();
268+
expect(collection.countDocuments.mock.calls[0][0]._id.toString()).toEqual(processId);
261269
});
262270

263271
it("should return false is the process doesn't exist", async () => {
264272
const processId = 'b2d39a1f-88bb-450e-95c5-feeffe95abe6';
265-
const countDocuments = jest.spyOn(collection, 'countDocuments').mockResolvedValue(0);
266273

267274
const exists = await service.has(processId);
268275
expect(exists).toBe(false);
269276

270-
expect(countDocuments).toHaveBeenCalled();
271-
expect(countDocuments.mock.calls[0][0]._id.toString()).toEqual(processId);
277+
expect(collection.countDocuments).toHaveBeenCalled();
278+
expect(collection.countDocuments.mock.calls[0][0]._id.toString()).toEqual(processId);
272279
});
273280
});
274281

@@ -334,7 +341,7 @@ describe('ProcessService', () => {
334341
const processDocument = { _id: bsonUUID(processId), scenario: bsonUUID(scenarioId), ...processData };
335342

336343
jest.spyOn(scenarios, 'get').mockResolvedValue({ ...scenario, _disabled: false });
337-
const findOne = jest.spyOn(collection, 'findOne').mockResolvedValue(processDocument);
344+
collection.findOne.mockResolvedValue(processDocument);
338345

339346
const process = await service.get(processId);
340347

@@ -365,17 +372,73 @@ describe('ProcessService', () => {
365372

366373
expect(scenarios.get).toHaveBeenCalledWith(scenarioId);
367374
expect(collection.findOne).toHaveBeenCalled();
368-
expect(findOne.mock.calls[0][0]._id.toString()).toEqual(processId);
375+
expect(collection.findOne.mock.calls[0][0]._id.toString()).toEqual(processId);
369376
});
370377

371378
it("should throw an error if the process doesn't exist", async () => {
372379
const processId = 'b2d39a1f-88bb-450e-95c5-feeffe95abe6';
373-
const findOne = jest.spyOn(collection, 'findOne').mockResolvedValue(null);
374380

375381
await expect(service.get(processId)).rejects.toThrow('Process not found');
376382

377-
expect(findOne).toHaveBeenCalled();
378-
expect(findOne.mock.calls[0][0]._id.toString()).toEqual(processId);
383+
expect(collection.findOne).toHaveBeenCalled();
384+
expect(collection.findOne.mock.calls[0][0]._id.toString()).toEqual(processId);
385+
});
386+
});
387+
388+
describe('save', () => {
389+
const scenario = normalize({
390+
title: 'simple workflow',
391+
states: {
392+
initial: {
393+
on: 'complete',
394+
goto: '(done)',
395+
},
396+
},
397+
});
398+
const scenarioId = uuid(scenario);
399+
400+
const process = instantiate(scenario);
401+
const { id: processId, scenario: _, actors, ...rest } = process;
402+
403+
const doc = {
404+
_id: bsonUUID(processId),
405+
scenario: bsonUUID(scenarioId),
406+
actors: Object.entries(actors).map(([key, actor]) => ({ _key: key, ...actor })),
407+
...rest,
408+
};
409+
410+
it('should save a process', async () => {
411+
await service.save(process);
412+
413+
expect(collection.replaceOne).toHaveBeenCalled();
414+
expect(collection.replaceOne.mock.calls[0][0]._id.toString()).toEqual(processId);
415+
expect(normalizeBsonUUID(collection.replaceOne.mock.calls[0][1] as any)).toEqual(normalizeBsonUUID(doc));
416+
});
417+
});
418+
419+
describe('step', () => {
420+
const scenario = normalize({
421+
title: 'simple workflow',
422+
states: {
423+
initial: {
424+
on: 'complete',
425+
goto: '(done)',
426+
},
427+
done: {},
428+
},
429+
});
430+
431+
it('should step through a process', async () => {
432+
const save = jest.spyOn(service, 'save');
433+
const process = instantiate(scenario);
434+
435+
const updatedProcess = await service.step(process, 'complete', { key: 'actor' });
436+
437+
expect(updatedProcess.id).toEqual(process.id);
438+
expect(updatedProcess.current.key).toEqual('(done)');
439+
440+
expect(save).toHaveBeenCalledWith(updatedProcess);
441+
expect(eventEmitter.emit).toBeCalledWith('process.stepped', updatedProcess);
379442
});
380443
});
381444
});

0 commit comments

Comments
 (0)