added REST model test, tests failing
This commit is contained in:
@@ -0,0 +1,370 @@
|
|||||||
|
# Model Tests for Flowable Apps
|
||||||
|
|
||||||
|
This page describes how to write automated tests for Flowable app models (BPMN processes, CMMN cases) in the `customer-work` module. Model tests run as plain JUnit tests against an in-memory H2 database — no Docker, no running platform needed.
|
||||||
|
|
||||||
|
Test data can be provided in three ways:
|
||||||
|
|
||||||
|
1. Directly in Java (simple, single-scenario tests)
|
||||||
|
2. As JSON parameter files (one scenario per file)
|
||||||
|
3. As Excel workbooks (many scenarios as rows; can be edited with Microsoft Excel or LibreOffice Calc)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project structure
|
||||||
|
|
||||||
|
All model-test code and data lives in the `customer-work` module:
|
||||||
|
|
||||||
|
```
|
||||||
|
customer-work/
|
||||||
|
├── src/main/java/com/customer/work/
|
||||||
|
│ └── service/ # custom runtime code used by the models
|
||||||
|
│ └── JsonUtils.java # JSON conversion helpers for service tasks
|
||||||
|
│
|
||||||
|
├── src/test/java/com/customer/work/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── TestConfiguration.java # test Spring configuration
|
||||||
|
│ └── model/ # the test framework
|
||||||
|
│ ├── FlowableModelTest.java # @FlowableModelTest meta-annotation
|
||||||
|
│ ├── FlowableModelTestUtils.java # main utility bean (start, complete, assert, load)
|
||||||
|
│ ├── FlowableExcelParser.java # reads .xlsx workbooks from the classpath
|
||||||
|
│ ├── FlowableExcelMapper.java # maps workbook rows to test parameters
|
||||||
|
│ ├── FlowableJsonParser.java # converts plain test data to model variable types
|
||||||
|
│ ├── EmailDto.java # captured email (subject, receivers, content)
|
||||||
|
│ ├── TestMailServer.java # GreenMail SMTP server for email assertions
|
||||||
|
│ ├── TestMailServerExtension.java # starts/stops the mail server per test
|
||||||
|
│ └── test/ # the actual test classes
|
||||||
|
│ └── ModelTest.java # tests for the TST_APP models
|
||||||
|
│
|
||||||
|
└── src/test/resources/
|
||||||
|
├── application.properties # H2 datasource, mail server port, app location
|
||||||
|
├── test-auto-deploy-apps/
|
||||||
|
│ └── TST_APP.zip # the app under test (exported from Design)
|
||||||
|
└── model/test/ # test data, one directory per model
|
||||||
|
├── C001/
|
||||||
|
│ └── T001.json # task completion payload
|
||||||
|
├── P001/
|
||||||
|
│ └── initiator.json # JSON parameter file
|
||||||
|
├── P002/
|
||||||
|
│ ├── boolean1.json … date.json # JSON parameter files
|
||||||
|
│ └── p002Test.xlsx # Excel workbook with test cases
|
||||||
|
└── P005/
|
||||||
|
└── T001.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Test data goes to `src/test/resources/model/test/<MODEL>/`, where `<MODEL>` is the short name of the model under test (e.g. `P002` for process `TST_P002`).
|
||||||
|
- JSON files are named after the scenario they cover (`boolean1.json`, `initiator.json`); task completion payloads are named after the task (`T001.json`).
|
||||||
|
- One Excel workbook per model (`p002Test.xlsx`) holds all data-driven cases for that model.
|
||||||
|
- Test classes live in `com.customer.work.model.test`; the framework classes in `com.customer.work.model` normally do not need to be touched when adding new tests.
|
||||||
|
|
||||||
|
## 2. How it works
|
||||||
|
|
||||||
|
Every test deploys the app under test and starts the model through a generated **wrapper process**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Wrapper process <KEY>_T] → Call Activity → [Process under test <KEY>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper is generated at runtime by `FlowableModelTestUtils.createRootTestProcessInstance(testKey, variables)`. This makes the test realistic for models that read or write variables on their **root** process (e.g. via `varutil` with the `root.` prefix), and it allows testing the model's **in/out parameter mappings**:
|
||||||
|
|
||||||
|
- Variables passed at the top level are set on the **wrapper (root) process**.
|
||||||
|
- The special variable `__IN` (a map) creates **in-mappings** on the call activity: each entry `"name": value` maps `${__IN.name}` to the variable `name` of the called process.
|
||||||
|
- The special variable `__OUT` (a map) creates **out-mappings**: each entry `"source": "target"` copies variable `source` of the called process to variable `target` of the root process when the call activity completes.
|
||||||
|
|
||||||
|
The method returns the IDs of both instances:
|
||||||
|
|
||||||
|
| Key | Content |
|
||||||
|
|---|---|
|
||||||
|
| `ROOT_PROCESS_ID` | ID of the generated wrapper (root) process instance |
|
||||||
|
| `TEST_PROCESS_ID` | ID of the process under test (the call activity child) |
|
||||||
|
|
||||||
|
Assertions are usually made against the **historic payload of the root process** (`getHistProcessPayload`), so they see both the root variables and everything mapped out of the model under test.
|
||||||
|
|
||||||
|
### Test infrastructure
|
||||||
|
|
||||||
|
| Piece | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `@FlowableModelTest` | Meta-annotation: boots the Spring context with all Flowable engine test extensions, makes each test `@Transactional` (automatic rollback) and starts the test mail server |
|
||||||
|
| `FlowableModelTestUtils` | The main utility bean: start instances, complete tasks, execute timers, load test data, assert emails / audit records |
|
||||||
|
| `FlowableExcelParser` / `FlowableExcelMapper` | Read Excel workbooks from the classpath and turn each data row into test parameters |
|
||||||
|
| `TestMailServer` (GreenMail) | Captures emails sent by the model; configured via `flowable.mail.server.host/port` in `src/test/resources/application.properties` |
|
||||||
|
|
||||||
|
### App deployment
|
||||||
|
|
||||||
|
The app under test must be available as a zip in `src/test/resources/test-auto-deploy-apps/` (e.g. `TST_APP.zip`). It is auto-deployed at context start via:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
flowable.app.resource-location=classpath*:/test-auto-deploy-apps/
|
||||||
|
```
|
||||||
|
|
||||||
|
To refresh the zip from a running Flowable Design, temporarily enable the `exportApp` test (remove `@Disabled`) and run it once:
|
||||||
|
|
||||||
|
```java
|
||||||
|
flowableModelTest.exportApp("http://localhost:8106", "myWorkspace", "TST_APP", "admin", "test");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Writing a model test class
|
||||||
|
|
||||||
|
Annotate the class with `@FlowableModelTest` and inject `FlowableModelTestUtils`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@FlowableModelTest
|
||||||
|
public class ModelTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected FlowableModelTestUtils flowableModelTest;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void p005HappyPathTest() {
|
||||||
|
// Start TST_P005 wrapped in a root process, no start variables
|
||||||
|
ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P005", flowableModelTest.emptyMap());
|
||||||
|
|
||||||
|
// Work on the open user task
|
||||||
|
flowableModelTest.claimOpenTask("TST_P005_T001", "admin");
|
||||||
|
flowableModelTest.completeOpenTask("TST_P005_T001",
|
||||||
|
flowableModelTest.loadObjectNodeFromResources("model/test/P005/T001.json"), null);
|
||||||
|
|
||||||
|
// Assert against the historic root process payload
|
||||||
|
Map<String, Object> rootPayload = flowableModelTest.getHistProcessPayload(
|
||||||
|
processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
|
||||||
|
Assertions.assertThat(rootPayload.get("dataEntry")).isEqualTo("my root text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frequently used utility methods
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `createRootTestProcessInstance(key, vars)` | Deploy wrapper + start the model under test, returns the two instance IDs |
|
||||||
|
| `startCaseInstance(key, vars)` | Start a CMMN case directly |
|
||||||
|
| `setTestAuthenticatedUser(userId, tenantId, groups...)` | Set the authenticated user for subsequent engine calls |
|
||||||
|
| `claimOpenTask(taskKey, userId)` / `completeOpenTask(taskKey, vars, outcome)` | Claim / complete the single open task with the given task definition key. `vars` may be flat (`"a.b": 1`) — existing task variables are merged automatically |
|
||||||
|
| `executeTimer(processId)` | Fire the active timer job of the given process instance |
|
||||||
|
| `getHistProcessPayload(id)` / `getRuntimeCasePayload(id)` / `getHistoryCasePayload(id)` | Read variables for assertions |
|
||||||
|
| `getMailList()` | All emails captured by the test mail server |
|
||||||
|
| `getAuditTrail()` | All audit records |
|
||||||
|
| `loadObjectNodeFromResources(path)` / `loadTestVarsFromResources(path)` | Load JSON test data from `src/test/resources` (see below) |
|
||||||
|
| `getJsonArgumentsFromExcel(path)` / `getObjArgumentsFromExcel(path)` | Excel rows as JUnit parameterized-test arguments (see below) |
|
||||||
|
| `testJsonExcelRow(path, row)` / `testObjExcelRow(path, row)` | Run one Excel row: start, fire timers, assert payload / audit / email counts |
|
||||||
|
|
||||||
|
### Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C-2025.2
|
||||||
|
./mvnw test -pl customer-work -Dtest=ModelTest # one class
|
||||||
|
./mvnw test -pl customer-work # whole module
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JSON parameter files
|
||||||
|
|
||||||
|
JSON files define the start parameters for one test scenario. They live under `src/test/resources/model/test/<MODEL>/` and are loaded with `loadTestVarsFromResources(path)`.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
All parameters must be declared **explicitly** in one of three top-level blocks:
|
||||||
|
|
||||||
|
| Block | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `__ROOT` | Variables set on the root (wrapper) process |
|
||||||
|
| `__IN` | In-mappings of the call activity: `"name": value` passes the value as variable `name` into the model under test |
|
||||||
|
| `__OUT` | Out-mappings: `"source": "target"` copies variable `source` of the model to variable `target` of the root process on completion |
|
||||||
|
|
||||||
|
Every block is optional, but **any other top-level field fails the test** with:
|
||||||
|
|
||||||
|
```
|
||||||
|
IllegalArgumentException: Implicit parameter 'param' in model/test/P002/boolean1.json:
|
||||||
|
parameters must be declared explicitly inside __ROOT, __IN or __OUT
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys inside `__ROOT` must not start with `__`.
|
||||||
|
|
||||||
|
### Example: `model/test/P002/boolean1.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"__ROOT": {
|
||||||
|
"param": true
|
||||||
|
},
|
||||||
|
"__IN": {
|
||||||
|
"param": false
|
||||||
|
},
|
||||||
|
"__OUT": {
|
||||||
|
"result": "out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This sets `param = true` on the root process, passes `param = false` into `TST_P002`, and copies the model's `result` variable to the root variable `out` when the model completes.
|
||||||
|
|
||||||
|
### Example: using JSON files in a parameterized test
|
||||||
|
|
||||||
|
```java
|
||||||
|
private Stream<Arguments> p002TestData() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("model/test/P002/boolean1.json", true, false),
|
||||||
|
Arguments.of("model/test/P002/int.json", 123, 456)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("p002TestData")
|
||||||
|
public void p002Test(String path, Object result, Object out) {
|
||||||
|
ObjectNode processIds = flowableModelTest.createRootTestProcessInstance("TST_P002",
|
||||||
|
flowableModelTest.loadTestVarsFromResources(path));
|
||||||
|
Map<String, Object> rootPayload = flowableModelTest.getHistProcessPayload(
|
||||||
|
processIds.get(FlowableModelTestUtils.ROOT_PROCESS_ID).asText());
|
||||||
|
|
||||||
|
Assertions.assertThat(rootPayload.get("result")).isEqualTo(result);
|
||||||
|
Assertions.assertThat(rootPayload.get("out")).isEqualTo(out);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Excel test workbooks
|
||||||
|
|
||||||
|
For data-driven testing of many scenarios, define one workbook per model, e.g. `src/test/resources/model/test/P002/p002Test.xlsx`. Both **Microsoft Excel** and **LibreOffice Calc** can be used to create and edit the file (`.xlsx` format).
|
||||||
|
|
||||||
|
Each **sheet** is a group of test cases, each **data row** is one test case that starts the model, optionally fires timers, and asserts the results.
|
||||||
|
|
||||||
|
### Sheet layout
|
||||||
|
|
||||||
|
| Row | Content |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Path** of each column's value (see path notation below) |
|
||||||
|
| 2 | **Type** of each column: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Date` |
|
||||||
|
| 3+ | One test case per row |
|
||||||
|
|
||||||
|
Parsing rules:
|
||||||
|
|
||||||
|
- The column count is taken from row 1: columns are read from the first cell up to the **first empty header cell**.
|
||||||
|
- Rows are read until the **first completely empty row** — everything below is ignored (useful for comments or temporarily disabled cases).
|
||||||
|
- **Empty cells are skipped** — the variable / check is simply not used for that row.
|
||||||
|
- Formulas are evaluated (e.g. `=TRUE()`); the displayed value is what counts.
|
||||||
|
|
||||||
|
### Path notation (row 1)
|
||||||
|
|
||||||
|
The path decides where the value goes. Nested objects use `.`, array elements use `[index]`:
|
||||||
|
|
||||||
|
| Path prefix | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `root.<name>` | Variable on the root process (like `__ROOT` in JSON) |
|
||||||
|
| `in.<name>` | In-mapping into the model (like `__IN`) |
|
||||||
|
| `out.<source>` | Out-mapping; the cell value is the **target** variable name (like `__OUT`) |
|
||||||
|
| `id` | **Required.** Process definition key of the model under test, e.g. `TST_P002` |
|
||||||
|
| `timer` | Number of timer jobs to fire after start (Integer) |
|
||||||
|
| `test.<name>` | Expected value of variable `<name>` in the root process payload after the run |
|
||||||
|
| `audit` | Expected total number of audit records (Integer) |
|
||||||
|
| `email` | Expected total number of sent emails (Integer) |
|
||||||
|
|
||||||
|
Nested examples:
|
||||||
|
|
||||||
|
| Header | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `root.param` | Root variable `param` |
|
||||||
|
| `root.param[0]`, `root.param[1]` | Root variable `param` as array with two elements |
|
||||||
|
| `root.items[1].name` | Root variable `items`, array whose second element is an object with field `name` |
|
||||||
|
| `test.result[0]` | Asserts the first element of array variable `result` |
|
||||||
|
|
||||||
|
Array elements that are skipped (e.g. only `[1]` is filled) are padded with empty objects, because Flowable cannot store `null` list elements.
|
||||||
|
|
||||||
|
### Special cell values
|
||||||
|
|
||||||
|
| Value | In input columns (`root.`, `in.`, `out.`) | In `test.` columns |
|
||||||
|
|---|---|---|
|
||||||
|
| *(empty cell)* | Variable not set | No check |
|
||||||
|
| `__NULL` | Variable set to JSON `null` | No check (a null expectation matches anything) |
|
||||||
|
| `__N_A` | – | Asserts the variable/field is **absent or null** |
|
||||||
|
| `__EMPTY` | Empty string `""` (type `String` only) | Expects `""` |
|
||||||
|
| `__BLANK` | Single space `" "` (type `String` only) | Expects `" "` |
|
||||||
|
|
||||||
|
### Types (row 2)
|
||||||
|
|
||||||
|
| Type | Cell content |
|
||||||
|
|---|---|
|
||||||
|
| `String` | Any text |
|
||||||
|
| `Boolean` | `TRUE` / `FALSE` (or a formula like `=TRUE()`) |
|
||||||
|
| `Integer`, `Long`, `Double` | Numeric value |
|
||||||
|
| `Date` | A date cell displayed as `yyyy-MM-dd` or `yyyy-MM-dd HH:mm:ss`. The displayed (local) date is interpreted as **UTC** |
|
||||||
|
|
||||||
|
### Example sheet
|
||||||
|
|
||||||
|
| | A | B | C | D | E | F | G | H |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **1** | `root.nr` | `root.param` | `in.param` | `out.result` | `id` | `timer` | `test.result` | `test.outResult` |
|
||||||
|
| **2** | `String` | `String` | `String` | `String` | `String` | `Integer` | `String` | `String` |
|
||||||
|
| **3** | 11 | | | | TST_P002 | | | |
|
||||||
|
| **4** | 12 | hello root | hello | outResult | TST_P002 | 1 | hello root | hello |
|
||||||
|
| **5** | 13 | | hello | outResult | TST_P002 | 0 | `__N_A` | `__NULL` |
|
||||||
|
|
||||||
|
- Row 3: starts `TST_P002` with no parameters and no checks (smoke test).
|
||||||
|
- Row 4: sets root and in parameters, maps `result` out to `outResult`, fires one timer, then asserts `result == "hello root"` and `outResult == "hello"`.
|
||||||
|
- Row 5: no root param; asserts that `result` was **not** set (`__N_A`) and skips the `outResult` check.
|
||||||
|
|
||||||
|
Tip: a column like `root.nr` is a handy row number — it appears in the test log line of every row, which makes failures easy to locate.
|
||||||
|
|
||||||
|
### How assertions work
|
||||||
|
|
||||||
|
After the run, the expected values from the `test.` columns are compared as a **subset** against the historic root process payload: every expected field must exist with the same (string-compared) value; extra variables in the payload are ignored. On failure the test prints both sides:
|
||||||
|
|
||||||
|
```
|
||||||
|
test : {"result":"hello root","outResult":"hello"}
|
||||||
|
root : {"nr":"12","param":"hello root", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `audit` / `email` columns are present, the total counts are asserted as well.
|
||||||
|
|
||||||
|
### Wiring the workbook into JUnit
|
||||||
|
|
||||||
|
Every data row of every sheet becomes one JUnit test invocation:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private Stream<Arguments> p002ExcelTestData() {
|
||||||
|
return flowableModelTest.getJsonArgumentsFromExcel("model/test/P002/p002Test.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("p002ExcelTestData")
|
||||||
|
public void p002ExcelTest(String path, JsonNode argument) {
|
||||||
|
flowableModelTest.testJsonExcelRow(path, argument);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Obj` variant works with plain Java maps and additionally returns a result map that can drive detailed email / audit assertions (they only run for rows where the `email` / `audit` count is > 0):
|
||||||
|
|
||||||
|
```java
|
||||||
|
private Stream<Arguments> p002ExcelTestData2() {
|
||||||
|
return flowableModelTest.getObjArgumentsFromExcel("model/test/P002/p002Test.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("p002ExcelTestData2")
|
||||||
|
public void p002ExcelTest2(String path, Map<String, Object> argument) {
|
||||||
|
Map<String, Object> result = flowableModelTest.testObjExcelRow(path, argument);
|
||||||
|
flowableModelTest.checkAndAssertEmail(result, 1,
|
||||||
|
"Email", "Test", "test@flowable.com");
|
||||||
|
flowableModelTest.checkAndAssertAuditRecord(result, 1,
|
||||||
|
"Audit record", "TST_P002 Audit trail entry", "system", null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause / fix |
|
||||||
|
|---|---|
|
||||||
|
| `Implicit parameter 'x' in <file>` | A top-level field outside `__ROOT` / `__IN` / `__OUT` in a JSON parameter file — move it into the right block |
|
||||||
|
| `Resource not found: <path>` | Path is relative to `src/test/resources` and is loaded from the classpath — check spelling and location |
|
||||||
|
| `Column 'id' missing in row of <file>` | The Excel sheet has no `id` column, or the cell is empty for that row |
|
||||||
|
| `No open task with key X found` | The model did not reach the expected user task — or more than one task with that key is open |
|
||||||
|
| `No active timer` | The `timer` count in the row is higher than the number of timer jobs the model actually creates |
|
||||||
|
| `Missing ']' in path` / `Invalid array index in path` | Malformed header path in row 1, e.g. `root.param[0` or `root.param[a]` |
|
||||||
|
| `Variable type not supported: X` | Typo in row 2 — allowed: `String`, `Boolean`, `Integer`, `Long`, `Double`, `Date` |
|
||||||
|
| Subset assertion fails with `test:` / `root:` output | Compare both printed JSON documents; remember values are compared as strings and `__N_A` requires absence |
|
||||||
@@ -30,7 +30,7 @@ public class SecurityHttpBasicConfiguration {
|
|||||||
@Order(10)
|
@Order(10)
|
||||||
public SecurityFilterChain basicDefaultSecurity(HttpSecurity http, ObjectProvider<FlowableHttpSecurityCustomizer> httpSecurityCustomizers) throws Exception {
|
public SecurityFilterChain basicDefaultSecurity(HttpSecurity http, ObjectProvider<FlowableHttpSecurityCustomizer> httpSecurityCustomizers) throws Exception {
|
||||||
for (FlowableHttpSecurityCustomizer customizer : httpSecurityCustomizers.orderedStream()
|
for (FlowableHttpSecurityCustomizer customizer : httpSecurityCustomizers.orderedStream()
|
||||||
.toList()) {
|
.collect(Collectors.toList())) {
|
||||||
customizer.customize(http);
|
customizer.customize(http);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,741 @@
|
|||||||
|
package com.customer.work.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.flowable.cmmn.api.CmmnRuntimeService;
|
||||||
|
import org.flowable.cmmn.api.runtime.CaseInstance;
|
||||||
|
import org.flowable.cmmn.api.runtime.PlanItemInstance;
|
||||||
|
import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity;
|
||||||
|
import org.flowable.engine.RuntimeService;
|
||||||
|
import org.flowable.engine.delegate.DelegateExecution;
|
||||||
|
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
||||||
|
import org.flowable.engine.runtime.Execution;
|
||||||
|
import org.flowable.engine.runtime.ProcessInstance;
|
||||||
|
import org.flowable.variable.api.delegate.VariableScope;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General-purpose Flowable variable utility bean for tracking variable changes
|
||||||
|
* and for null-safe variable access.
|
||||||
|
* Usable in BPMN process and CMMN case backend expressions:
|
||||||
|
* ${varutil.trackVars('root.snapshot', 'order.customer.name,order.total,status')}
|
||||||
|
* ${varutil.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope
|
||||||
|
* ${varutil.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope
|
||||||
|
* ${varutil.get(execution, 'orders[2].customer.name')} — null-safe read
|
||||||
|
* Variable paths use dot notation; a leading "root." prefix addresses the root
|
||||||
|
* instance of a nested case/process hierarchy. A "[]" in a path expands over all
|
||||||
|
* elements of the JSON/List array at that point, e.g. "status[]" (simple types),
|
||||||
|
* "orders[].name" (object fields) or "orders[].items[].qty" (nested arrays).
|
||||||
|
* A path that just names an array ("status", "orders[].items") tracks all its
|
||||||
|
* entries as well, as if "[]" were appended.
|
||||||
|
* The get/getOrDefault/exists/isEmpty/isNotEmpty/equals/notEquals/contains/
|
||||||
|
* containsAny/lowerThan(OrEquals)/greaterThan(OrEquals)/base64 methods are
|
||||||
|
* null-safe, path-based counterparts of the Flowable var: expression functions.
|
||||||
|
*/
|
||||||
|
@Component("varUtil")
|
||||||
|
public class VarUtil {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtil.class);
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER;
|
||||||
|
static {
|
||||||
|
MAPPER = new ObjectMapper();
|
||||||
|
MAPPER.registerModule(new JavaTimeModule());
|
||||||
|
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RuntimeService runtimeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CmmnRuntimeService cmmnRuntimeService;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public API — called from Flowable expressions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks which of the given comma-separated variable paths changed since the
|
||||||
|
* last call and returns a JSON array describing each change.
|
||||||
|
* Example call:
|
||||||
|
* ${varutil.trackVars(self, 'root.snapshot', 'root.status, status')}
|
||||||
|
* Example return value:
|
||||||
|
* [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}]
|
||||||
|
* Paths containing "[]" are expanded per array element (e.g. "status[]",
|
||||||
|
* "orders[].name"); changes are then reported per concrete index, such as
|
||||||
|
* {"path":"orders[2].name",...}. A path that resolves to an array without an
|
||||||
|
* explicit "[]" is expanded the same way. Elements added since the last call
|
||||||
|
* are reported with oldValue null, removed elements with newValue null.
|
||||||
|
*/
|
||||||
|
public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) {
|
||||||
|
|
||||||
|
// Reject blank parameters early — report "no changes" instead of failing,
|
||||||
|
// so a misconfigured expression cannot break the surrounding process/case.
|
||||||
|
if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) {
|
||||||
|
LOGGER.debug("{}.trackVars: empty parameters", getClass().getName());
|
||||||
|
return MAPPER.createArrayNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a resolvable scope there is nothing to read from or write to.
|
||||||
|
if (currentScope == null) {
|
||||||
|
LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName());
|
||||||
|
return MAPPER.createArrayNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the CSV into individual variable paths, dropping blanks and whitespace.
|
||||||
|
List<String> paths = Arrays.stream(variablePathsCsv.split(","))
|
||||||
|
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||||
|
|
||||||
|
// Load the previous values (persisted as a JSON string variable at
|
||||||
|
// snapshotPath) to compare the current values against.
|
||||||
|
Map<String, JsonNode> oldSnapshot = loadSnapshot(currentScope, snapshotPath);
|
||||||
|
Map<String, JsonNode> newSnapshot = new HashMap<>();
|
||||||
|
ArrayNode changes = MAPPER.createArrayNode();
|
||||||
|
|
||||||
|
// For every tracked path: expand "[]" over the current array elements,
|
||||||
|
// then read each concrete value, normalize it so it compares consistently
|
||||||
|
// with the reloaded snapshot, and record a change entry whenever old != new.
|
||||||
|
for (String path : paths) {
|
||||||
|
for (String concretePath : expandArrayPath(currentScope, path)) {
|
||||||
|
JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, concretePath)));
|
||||||
|
newSnapshot.put(concretePath, newValue);
|
||||||
|
|
||||||
|
JsonNode oldValue = oldSnapshot.getOrDefault(concretePath, MAPPER.nullNode());
|
||||||
|
if (!oldValue.equals(newValue)) {
|
||||||
|
addChange(changes, concretePath, oldValue, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old snapshot entries matching the spec but no longer produced by the
|
||||||
|
// expansion belong to removed array elements (or a value that changed
|
||||||
|
// shape between scalar and array) — report them as changes to null and
|
||||||
|
// let them drop out of the new snapshot.
|
||||||
|
java.util.regex.Pattern pattern = arraySpecPattern(path);
|
||||||
|
for (Map.Entry<String, JsonNode> old : oldSnapshot.entrySet()) {
|
||||||
|
if (pattern.matcher(old.getKey()).matches()
|
||||||
|
&& !newSnapshot.containsKey(old.getKey())
|
||||||
|
&& !old.getValue().isNull()) {
|
||||||
|
addChange(changes, old.getKey(), old.getValue(), MAPPER.nullNode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the current values as the reference snapshot for the next call.
|
||||||
|
saveSnapshot(currentScope, snapshotPath, newSnapshot);
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null-safe variable functions — path-based counterparts of the Flowable
|
||||||
|
// var: expression functions (var:get, var:exists, ...). All accept the same
|
||||||
|
// path syntax as trackVars and never throw on unresolvable paths.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null-safe read: returns the value at the path, or null when any part of
|
||||||
|
* the path cannot be resolved (missing variable, null intermediate, index
|
||||||
|
* out of bounds). A path with an explicit "[]" returns the flattened List
|
||||||
|
* of entry values, e.g. "orders[].name" -> ["n1", "n2"].
|
||||||
|
*/
|
||||||
|
public Object get(VariableScope scope, String path) {
|
||||||
|
return resolve(scope, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like {@link #get}, but returns {@code defaultValue} instead of null. */
|
||||||
|
public Object getOrDefault(VariableScope scope, String path, Object defaultValue) {
|
||||||
|
Object value = resolve(scope, path);
|
||||||
|
return value != null ? value : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the path resolves to a non-null value. */
|
||||||
|
public boolean exists(VariableScope scope, String path) {
|
||||||
|
return resolve(scope, path) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the path resolves to null, an empty String, or an empty
|
||||||
|
* List/Map/array/JSON container.
|
||||||
|
*/
|
||||||
|
public boolean isEmpty(VariableScope scope, String path) {
|
||||||
|
return isEmptyValue(resolve(scope, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of {@link #isEmpty}. */
|
||||||
|
public boolean isNotEmpty(VariableScope scope, String path) {
|
||||||
|
return !isEmpty(scope, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the value at the path equals the given value. Both sides are
|
||||||
|
* normalized to JSON before comparing, so a Long 5 equals an Integer 5 and
|
||||||
|
* a JsonNode field equals its plain Java counterpart. A null value only
|
||||||
|
* equals an unresolvable/null path.
|
||||||
|
*/
|
||||||
|
public boolean equals(VariableScope scope, String path, Object value) {
|
||||||
|
return jsonEquals(resolve(scope, path), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of {@link #equals(VariableScope, String, Object)}. */
|
||||||
|
public boolean notEquals(VariableScope scope, String path, Object value) {
|
||||||
|
return !equals(scope, path, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the value at the path contains ALL given values: substring for
|
||||||
|
* Strings, element containment (JSON-normalized) for List/array/JSON array.
|
||||||
|
* False for null or any other value type.
|
||||||
|
*/
|
||||||
|
public boolean contains(VariableScope scope, String path, Object... values) {
|
||||||
|
Object container = resolve(scope, path);
|
||||||
|
if (container == null || values == null || values.length == 0) return false;
|
||||||
|
for (Object value : values) {
|
||||||
|
if (!containsValue(container, value)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like {@link #contains}, but true when ANY of the given values is contained. */
|
||||||
|
public boolean containsAny(VariableScope scope, String path, Object... values) {
|
||||||
|
Object container = resolve(scope, path);
|
||||||
|
if (container == null || values == null) return false;
|
||||||
|
for (Object value : values) {
|
||||||
|
if (containsValue(container, value)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the numeric value at the path is lower than the given number. */
|
||||||
|
public boolean lowerThan(VariableScope scope, String path, Object value) {
|
||||||
|
Integer cmp = compareNumeric(resolve(scope, path), value);
|
||||||
|
return cmp != null && cmp < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the numeric value at the path is lower than or equal to the given number. */
|
||||||
|
public boolean lowerThanOrEquals(VariableScope scope, String path, Object value) {
|
||||||
|
Integer cmp = compareNumeric(resolve(scope, path), value);
|
||||||
|
return cmp != null && cmp <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the numeric value at the path is greater than the given number. */
|
||||||
|
public boolean greaterThan(VariableScope scope, String path, Object value) {
|
||||||
|
Integer cmp = compareNumeric(resolve(scope, path), value);
|
||||||
|
return cmp != null && cmp > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the numeric value at the path is greater than or equal to the given number. */
|
||||||
|
public boolean greaterThanOrEquals(VariableScope scope, String path, Object value) {
|
||||||
|
Integer cmp = compareNumeric(resolve(scope, path), value);
|
||||||
|
return cmp != null && cmp >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64-encodes the String (UTF-8) or byte[] value at the path; null for
|
||||||
|
* anything else.
|
||||||
|
*/
|
||||||
|
public String base64(VariableScope scope, String path) {
|
||||||
|
Object value = resolve(scope, path);
|
||||||
|
if (value instanceof byte[] bytes) return java.util.Base64.getEncoder().encodeToString(bytes);
|
||||||
|
if (value instanceof String s)
|
||||||
|
return java.util.Base64.getEncoder().encodeToString(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a path to its value. A path with an explicit "[]" resolves to
|
||||||
|
* the flattened List of the expanded entry values; unlike trackVars, a bare
|
||||||
|
* array name is NOT expanded — it resolves to the array itself.
|
||||||
|
*/
|
||||||
|
private Object resolve(VariableScope scope, String path) {
|
||||||
|
if (scope == null || path == null || path.isBlank()) return null;
|
||||||
|
String trimmed = path.trim();
|
||||||
|
if (trimmed.contains("[]")) {
|
||||||
|
List<Object> values = new java.util.ArrayList<>();
|
||||||
|
for (String concretePath : expandArrayPath(scope, trimmed)) {
|
||||||
|
values.add(readVariableFromPath(scope, concretePath));
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
return readVariableFromPath(scope, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Path resolution
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands every "[]" in a path over the elements of the array currently found
|
||||||
|
* at that point, e.g. "orders[].name" with two orders becomes
|
||||||
|
* ["orders[0].name", "orders[1].name"]. Works recursively, so nested arrays
|
||||||
|
* ("a[].b[]", "a[][]") expand to all combinations. A path without "[]" that
|
||||||
|
* resolves to an array is expanded as if "[]" were appended; other paths are
|
||||||
|
* returned as-is. A "[]" that does not hit an array expands to nothing.
|
||||||
|
*/
|
||||||
|
private List<String> expandArrayPath(VariableScope scope, String path) {
|
||||||
|
int idx = path.indexOf("[]");
|
||||||
|
if (idx < 0) {
|
||||||
|
// Bare path: when it resolves to an array, track all its entries as
|
||||||
|
// if "[]" were appended (recursing handles arrays of arrays).
|
||||||
|
Object value = readVariableFromPath(scope, path);
|
||||||
|
if (!isArray(value)) return List.of(path);
|
||||||
|
List<String> result = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < arraySize(value); i++) {
|
||||||
|
result.addAll(expandArrayPath(scope, path + "[" + i + "]"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String prefix = path.substring(0, idx);
|
||||||
|
String suffix = path.substring(idx + 2);
|
||||||
|
|
||||||
|
int size = arraySize(readVariableFromPath(scope, prefix));
|
||||||
|
List<String> result = new java.util.ArrayList<>();
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
result.addAll(expandArrayPath(scope, prefix + "[" + i + "]" + suffix));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a regex matching all concrete expansions of a path spec,
|
||||||
|
* e.g. "orders[].name" matches "orders[0].name", "orders[12].name", ...
|
||||||
|
* The trailing index group also covers bare-array auto-expansion, so "status"
|
||||||
|
* matches "status", "status[3]" and "status[0][1]". Used to find snapshot
|
||||||
|
* entries of elements that no longer exist.
|
||||||
|
*/
|
||||||
|
private static java.util.regex.Pattern arraySpecPattern(String spec) {
|
||||||
|
StringBuilder regex = new StringBuilder();
|
||||||
|
for (String literal : spec.split("\\[\\]", -1)) {
|
||||||
|
if (regex.length() > 0) regex.append("\\[\\d+\\]");
|
||||||
|
regex.append(java.util.regex.Pattern.quote(literal));
|
||||||
|
}
|
||||||
|
regex.append("(\\[\\d+\\])*");
|
||||||
|
return java.util.regex.Pattern.compile(regex.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the container types expanded by "[]": List, Object[] or JSON array. */
|
||||||
|
private static boolean isArray(Object obj) {
|
||||||
|
return obj instanceof List<?> || obj instanceof Object[]
|
||||||
|
|| (obj instanceof JsonNode jn && jn.isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the element count of a List, Object[] or JSON array, else 0. */
|
||||||
|
private static int arraySize(Object obj) {
|
||||||
|
if (obj instanceof List<?> list) return list.size();
|
||||||
|
if (obj instanceof Object[] arr) return arr.length;
|
||||||
|
if (obj instanceof JsonNode jn && jn.isArray()) return jn.size();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a value via a dot-notation path, e.g. "order.customer.name".
|
||||||
|
* The first segment names a Flowable variable; the remaining segments navigate
|
||||||
|
* into that value (maps, lists/arrays by index, JsonNodes, POJOs — see
|
||||||
|
* {@link #getNestedVariable}). Segments may carry "[n]" index suffixes to
|
||||||
|
* address array elements, e.g. "orders[0].name" or "matrix[1][2]".
|
||||||
|
* Returns null when any segment cannot be resolved.
|
||||||
|
*/
|
||||||
|
private Object readVariableFromPath(VariableScope scope, String path) {
|
||||||
|
String[] segments = path.split("\\.", -1);
|
||||||
|
if (segments.length < 1) return null;
|
||||||
|
|
||||||
|
// A leading "root." switches to the root instance of the surrounding
|
||||||
|
// case/process hierarchy before resolving the variable.
|
||||||
|
int startIndex = 0;
|
||||||
|
if ("root".equals(segments[0])) {
|
||||||
|
if (segments.length < 2) return null;
|
||||||
|
startIndex = 1;
|
||||||
|
scope = getRootScope(scope);
|
||||||
|
}
|
||||||
|
if (scope == null) return null;
|
||||||
|
|
||||||
|
// Read the top-level variable, then walk the remaining segments into it.
|
||||||
|
String first = segments[startIndex];
|
||||||
|
int bracket = first.indexOf('[');
|
||||||
|
Object currentValue = scope.getVariable(bracket < 0 ? first : first.substring(0, bracket));
|
||||||
|
if (bracket >= 0) currentValue = applyIndices(currentValue, first.substring(bracket));
|
||||||
|
|
||||||
|
for (int i = startIndex + 1; i < segments.length; i++) {
|
||||||
|
if (currentValue == null) return null;
|
||||||
|
String segment = segments[i];
|
||||||
|
bracket = segment.indexOf('[');
|
||||||
|
if (bracket < 0) {
|
||||||
|
currentValue = getNestedVariable(currentValue, segment);
|
||||||
|
} else {
|
||||||
|
if (bracket > 0) currentValue = getNestedVariable(currentValue, segment.substring(0, bracket));
|
||||||
|
currentValue = applyIndices(currentValue, segment.substring(bracket));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a chain of "[n]" index suffixes ("[0]", "[1][2]", ...) to a value. */
|
||||||
|
private static Object applyIndices(Object value, String indices) {
|
||||||
|
java.util.regex.Matcher m = INDEX_PATTERN.matcher(indices);
|
||||||
|
while (m.find()) {
|
||||||
|
if (value == null) return null;
|
||||||
|
value = getElement(value, Integer.parseInt(m.group(1)));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final java.util.regex.Pattern INDEX_PATTERN =
|
||||||
|
java.util.regex.Pattern.compile("\\[(\\d+)\\]");
|
||||||
|
|
||||||
|
/** Returns element i of a List, Object[] or JSON array (scalars unwrapped), else null. */
|
||||||
|
private static Object getElement(Object obj, int i) {
|
||||||
|
if (obj instanceof List<?> list) return i >= 0 && i < list.size() ? list.get(i) : null;
|
||||||
|
if (obj instanceof Object[] arr) return i >= 0 && i < arr.length ? arr[i] : null;
|
||||||
|
if (obj instanceof JsonNode jn && jn.isArray()) return unwrapJson(jn.get(i));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a value via a dot-notation path (same syntax as
|
||||||
|
* {@link #readVariableFromPath}). A single-segment path sets a plain Flowable
|
||||||
|
* variable; a nested path mutates the container inside the top-level variable
|
||||||
|
* and writes that variable back so the engine persists the change.
|
||||||
|
*/
|
||||||
|
private void writeVariableToPath(VariableScope scope, String path, Object value) {
|
||||||
|
String[] segments = path.split("\\.", -1);
|
||||||
|
if (segments.length < 1) return;
|
||||||
|
|
||||||
|
// A leading "root." redirects the write to the root instance of the
|
||||||
|
// surrounding case/process hierarchy.
|
||||||
|
VariableScope targetScope;
|
||||||
|
String[] varSegments;
|
||||||
|
if ("root".equals(segments[0])) {
|
||||||
|
if (segments.length < 2) return;
|
||||||
|
targetScope = getRootScope(scope);
|
||||||
|
varSegments = Arrays.copyOfRange(segments, 1, segments.length);
|
||||||
|
} else {
|
||||||
|
targetScope = scope;
|
||||||
|
varSegments = segments;
|
||||||
|
}
|
||||||
|
if (targetScope == null) return;
|
||||||
|
|
||||||
|
if (varSegments.length == 1) {
|
||||||
|
targetScope.setVariable(varSegments[0], value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested path: read the top-level variable, navigate to the parent node,
|
||||||
|
// mutate it in-place, then write the top-level variable back.
|
||||||
|
String topVar = varSegments[0];
|
||||||
|
Object topValue = targetScope.getVariable(topVar);
|
||||||
|
|
||||||
|
Object parent = topValue;
|
||||||
|
for (int i = 1; i < varSegments.length - 1; i++) {
|
||||||
|
if (parent == null) {
|
||||||
|
LOGGER.warn("varutil.writeVariableToPath: null at '{}' in path '{}'", varSegments[i - 1], path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parent = getNestedVariable(parent, varSegments[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setNestedValue(parent, varSegments[varSegments.length - 1], value, path)) return;
|
||||||
|
targetScope.setVariable(topVar, topValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets one key on a mutable container (Map or ObjectNode). Anything else —
|
||||||
|
* including POJOs, which are read-only for this bean — is rejected with a
|
||||||
|
* warning so a bad path never breaks the calling expression.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private boolean setNestedValue(Object parent, String key, Object value, String path) {
|
||||||
|
if (parent instanceof Map map) {
|
||||||
|
map.put(key, value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (parent instanceof ObjectNode on) {
|
||||||
|
on.set(key, toJson(value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LOGGER.warn("varutil.writeVariableToPath: cannot set '{}' on {} in path '{}'",
|
||||||
|
key, parent == null ? "null" : parent.getClass().getName(), path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Scope resolution
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Climbs from the given scope to the root VariableScope of the surrounding
|
||||||
|
* case/process hierarchy, following call-activity parents (BPMN) and
|
||||||
|
* parent/callback links (CMMN) across engine boundaries.
|
||||||
|
*/
|
||||||
|
private VariableScope getRootScope(VariableScope scope) {
|
||||||
|
if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId());
|
||||||
|
if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId());
|
||||||
|
if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the root scope starting from a BPMN process instance:
|
||||||
|
* 1. started by a call activity → recurse into the calling process instance,
|
||||||
|
* 2. started from a CMMN plan item (callback) → continue climbing in the case,
|
||||||
|
* 3. otherwise this process instance is itself the root.
|
||||||
|
* NOTE: the returned object is a detached query result used as VariableScope;
|
||||||
|
* its variable access only works because expression evaluation runs inside an
|
||||||
|
* active Flowable command context.
|
||||||
|
*/
|
||||||
|
private VariableScope findBpmnRootScope(String processInstanceId) {
|
||||||
|
if (processInstanceId == null || runtimeService == null) return null;
|
||||||
|
try {
|
||||||
|
Execution piExec = runtimeService.createExecutionQuery()
|
||||||
|
.executionId(processInstanceId).singleResult();
|
||||||
|
if (piExec != null && piExec.getSuperExecutionId() != null) {
|
||||||
|
Execution superExec = runtimeService.createExecutionQuery()
|
||||||
|
.executionId(piExec.getSuperExecutionId()).singleResult();
|
||||||
|
if (superExec != null)
|
||||||
|
return findBpmnRootScope(superExec.getProcessInstanceId());
|
||||||
|
}
|
||||||
|
ProcessInstance pi = runtimeService.createProcessInstanceQuery()
|
||||||
|
.processInstanceId(processInstanceId).singleResult();
|
||||||
|
if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null
|
||||||
|
&& cmmnRuntimeService != null) {
|
||||||
|
PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery()
|
||||||
|
.planItemInstanceId(pi.getCallbackId()).singleResult();
|
||||||
|
if (planItem != null)
|
||||||
|
return findCmmnRootScope(planItem.getCaseInstanceId());
|
||||||
|
}
|
||||||
|
return (ExecutionEntity) piExec; // root: ExecutionEntity implements VariableScope
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.debug("{}.findBpmnRootScope: BPMN root scope climb failed: {}", getClass().getName(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the root scope starting from a CMMN case instance:
|
||||||
|
* 1. sub-case → recurse into the parent case,
|
||||||
|
* 2. started from a BPMN call/task (callback) → continue climbing in the process,
|
||||||
|
* 3. otherwise this case instance is itself the root.
|
||||||
|
* Same detached-query-result caveat as {@link #findBpmnRootScope}.
|
||||||
|
*/
|
||||||
|
private VariableScope findCmmnRootScope(String caseInstanceId) {
|
||||||
|
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
|
||||||
|
try {
|
||||||
|
CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery()
|
||||||
|
.caseInstanceId(caseInstanceId).singleResult();
|
||||||
|
if (ci == null) return null;
|
||||||
|
if (ci.getParentId() != null)
|
||||||
|
return findCmmnRootScope(ci.getParentId());
|
||||||
|
if (ci.getCallbackType() != null && ci.getCallbackId() != null
|
||||||
|
&& runtimeService != null) {
|
||||||
|
Execution callbackExec = runtimeService.createExecutionQuery()
|
||||||
|
.executionId(ci.getCallbackId()).singleResult();
|
||||||
|
if (callbackExec != null)
|
||||||
|
return findBpmnRootScope(callbackExec.getProcessInstanceId());
|
||||||
|
}
|
||||||
|
return (CaseInstanceEntity) ci; // root: CaseInstanceEntity implements VariableScope
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.debug("{}.findCmmnRootScope: CMMN root scope climb failed: {}", getClass().getName(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one path segment against a value: list/array index, map key,
|
||||||
|
* JsonNode field (scalars are unwrapped to plain Java values), and finally
|
||||||
|
* POJO access via getX()/isX() getters or a declared field.
|
||||||
|
*/
|
||||||
|
private static Object getNestedVariable(Object obj, String segment) {
|
||||||
|
// Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything.
|
||||||
|
if (segment == null || segment.isEmpty()) return null;
|
||||||
|
|
||||||
|
// Lists and arrays are addressed by numeric index, e.g. "items.0.name".
|
||||||
|
if (obj instanceof List<?> list) {
|
||||||
|
try {
|
||||||
|
int i = Integer.parseInt(segment);
|
||||||
|
return i >= 0 && i < list.size() ? list.get(i) : null;
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
if (obj instanceof Object[] arr) {
|
||||||
|
try {
|
||||||
|
int i = Integer.parseInt(segment);
|
||||||
|
return i >= 0 && i < arr.length ? arr[i] : null;
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
if (obj instanceof Map<?, ?> map) return map.get(segment);
|
||||||
|
|
||||||
|
// JsonNode: unwrap scalar fields to plain Java values so they compare and
|
||||||
|
// serialize the same way as values read from Map/POJO variables.
|
||||||
|
if (obj instanceof JsonNode jn) {
|
||||||
|
return unwrapJson(jn.get(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for POJOs: try bean getters first, then a declared field
|
||||||
|
// (climbing the class hierarchy). Read-only — writes reject POJO parents.
|
||||||
|
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
|
||||||
|
try {
|
||||||
|
return obj.getClass().getMethod("get" + cap).invoke(obj);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
try {
|
||||||
|
return obj.getClass().getMethod("is" + cap).invoke(obj);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
try {
|
||||||
|
java.lang.reflect.Field f = findField(obj.getClass(), segment);
|
||||||
|
if (f != null) { f.setAccessible(true); return f.get(obj); }
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
LOGGER.warn("varutil: cannot resolve '{}' on {}", segment, obj.getClass().getName());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up a declared field by name, walking up the class hierarchy. */
|
||||||
|
private static java.lang.reflect.Field findField(Class<?> c, String name) {
|
||||||
|
while (c != null && c != Object.class) {
|
||||||
|
try { return c.getDeclaredField(name); }
|
||||||
|
catch (NoSuchFieldException e) { c = c.getSuperclass(); }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Snapshot persistence
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the previous-values snapshot: reads the variable at {@code path}
|
||||||
|
* (persisted as a JSON string by {@link #saveSnapshot}) and parses it back
|
||||||
|
* into a path → value-node map. Returns an empty map when there is no
|
||||||
|
* snapshot yet (first call) or it is unreadable.
|
||||||
|
*/
|
||||||
|
private Map<String, JsonNode> loadSnapshot(VariableScope scope, String path) {
|
||||||
|
Object raw = readVariableFromPath(scope, path);
|
||||||
|
if (raw == null) return new HashMap<>();
|
||||||
|
try {
|
||||||
|
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
||||||
|
Map<String, Object> flat = MAPPER.readValue(json,
|
||||||
|
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
||||||
|
Map<String, JsonNode> result = new HashMap<>();
|
||||||
|
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warn("varutil: could not load snapshot at '{}': {}", path, e.getMessage());
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persists the snapshot map as a JSON string variable at {@code path}. */
|
||||||
|
private void saveSnapshot(VariableScope scope, String path, Map<String, JsonNode> snapshot) {
|
||||||
|
try {
|
||||||
|
writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("varutil: could not save snapshot at '{}'", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** True for null, an empty String, or an empty List/Map/array/JSON container. */
|
||||||
|
private static boolean isEmptyValue(Object value) {
|
||||||
|
if (value == null) return true;
|
||||||
|
if (value instanceof String s) return s.isEmpty();
|
||||||
|
if (value instanceof java.util.Collection<?> c) return c.isEmpty();
|
||||||
|
if (value instanceof Map<?, ?> m) return m.isEmpty();
|
||||||
|
if (value instanceof Object[] arr) return arr.length == 0;
|
||||||
|
if (value instanceof JsonNode jn) {
|
||||||
|
if (jn.isNull() || jn.isMissingNode()) return true;
|
||||||
|
if (jn.isTextual()) return jn.asText().isEmpty();
|
||||||
|
if (jn.isContainerNode()) return jn.size() == 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-normalized equality, so numeric types and JsonNode wrappers compare naturally. */
|
||||||
|
private static boolean jsonEquals(Object a, Object b) {
|
||||||
|
return normalize(toJson(a)).equals(normalize(toJson(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Containment test for {@link #contains}: substring or element equality. */
|
||||||
|
private static boolean containsValue(Object container, Object value) {
|
||||||
|
if (container instanceof String s) return value != null && s.contains(value.toString());
|
||||||
|
if (container instanceof java.util.Collection<?> c) {
|
||||||
|
for (Object element : c) if (jsonEquals(element, value)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (container instanceof Object[] arr) {
|
||||||
|
for (Object element : arr) if (jsonEquals(element, value)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (container instanceof JsonNode jn && jn.isArray()) {
|
||||||
|
for (JsonNode element : jn) if (jsonEquals(element, value)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric comparison via BigDecimal; null when either side is not a number. */
|
||||||
|
private static Integer compareNumeric(Object a, Object b) {
|
||||||
|
java.math.BigDecimal numA = toNumber(a);
|
||||||
|
java.math.BigDecimal numB = toNumber(b);
|
||||||
|
return numA == null || numB == null ? null : numA.compareTo(numB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a Number or numeric JsonNode to BigDecimal, anything else to null. */
|
||||||
|
private static java.math.BigDecimal toNumber(Object value) {
|
||||||
|
if (value instanceof JsonNode jn) return jn.isNumber() ? jn.decimalValue() : null;
|
||||||
|
if (value instanceof Number n) return new java.math.BigDecimal(n.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends one change entry to the result array. */
|
||||||
|
private static void addChange(ArrayNode changes, String path, JsonNode oldValue, JsonNode newValue) {
|
||||||
|
ObjectNode change = MAPPER.createObjectNode();
|
||||||
|
change.put("path", path);
|
||||||
|
change.set("oldValue", oldValue);
|
||||||
|
change.set("newValue", newValue);
|
||||||
|
changes.add(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unwraps a scalar JsonNode to its plain Java value; containers pass through. */
|
||||||
|
private static Object unwrapJson(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) return null;
|
||||||
|
if (node.isTextual()) return node.asText();
|
||||||
|
if (node.isBoolean()) return node.asBoolean();
|
||||||
|
if (node.isLong()) return node.asLong();
|
||||||
|
if (node.isInt()) return node.asInt();
|
||||||
|
if (node.isDouble()) return node.asDouble();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps any Java value into a JsonNode (null-safe). */
|
||||||
|
private static JsonNode toJson(Object value) {
|
||||||
|
if (value == null) return MAPPER.nullNode();
|
||||||
|
if (value instanceof JsonNode jn) return jn;
|
||||||
|
return MAPPER.valueToTree(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes and re-parses a node so its value types match what
|
||||||
|
* {@link #loadSnapshot} produces when reading the persisted snapshot back
|
||||||
|
* (e.g. a Long 5 read from a variable and an Integer 5 parsed from the
|
||||||
|
* snapshot JSON both become the same numeric node). Without this, unchanged
|
||||||
|
* numeric values would be reported as changes on every call.
|
||||||
|
*/
|
||||||
|
private static JsonNode normalize(JsonNode node) {
|
||||||
|
try {
|
||||||
|
return MAPPER.readTree(MAPPER.writeValueAsString(node));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
package com.customer.work.service;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|
||||||
import org.flowable.cmmn.api.CmmnRuntimeService;
|
|
||||||
import org.flowable.cmmn.api.runtime.CaseInstance;
|
|
||||||
import org.flowable.cmmn.api.runtime.PlanItemInstance;
|
|
||||||
import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity;
|
|
||||||
import org.flowable.engine.RuntimeService;
|
|
||||||
import org.flowable.engine.delegate.DelegateExecution;
|
|
||||||
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
|
||||||
import org.flowable.engine.runtime.Execution;
|
|
||||||
import org.flowable.engine.runtime.ProcessInstance;
|
|
||||||
import org.flowable.variable.api.delegate.VariableScope;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General-purpose Flowable variable utility bean for tracking variable changes.
|
|
||||||
* Usable in BPMN process and CMMN case backend expressions:
|
|
||||||
* ${varUtils.trackVars('root.snapshot', 'order.customer.name,order.total,status')}
|
|
||||||
* ${varUtils.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope
|
|
||||||
* ${varUtils.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope
|
|
||||||
* Variable paths use dot notation; a leading "root." prefix addresses the root
|
|
||||||
* instance of a nested case/process hierarchy.
|
|
||||||
*/
|
|
||||||
@Component("varUtils")
|
|
||||||
public class VarUtils {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
|
|
||||||
|
|
||||||
private static final ObjectMapper MAPPER;
|
|
||||||
static {
|
|
||||||
MAPPER = new ObjectMapper();
|
|
||||||
MAPPER.registerModule(new JavaTimeModule());
|
|
||||||
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RuntimeService runtimeService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private CmmnRuntimeService cmmnRuntimeService;
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Public API — called from Flowable expressions
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks which of the given comma-separated variable paths changed since the
|
|
||||||
* last call and returns a JSON array describing each change.
|
|
||||||
* Example call:
|
|
||||||
* ${varUtils.trackVars(self, 'root.snapshot', 'root.status, status')}
|
|
||||||
* Example return value:
|
|
||||||
* [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}]
|
|
||||||
*/
|
|
||||||
public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) {
|
|
||||||
|
|
||||||
// Reject blank parameters early — report "no changes" instead of failing,
|
|
||||||
// so a misconfigured expression cannot break the surrounding process/case.
|
|
||||||
if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) {
|
|
||||||
LOGGER.debug("{}.trackVars: empty parameters", getClass().getName());
|
|
||||||
return MAPPER.createArrayNode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a resolvable scope there is nothing to read from or write to.
|
|
||||||
if (currentScope == null) {
|
|
||||||
LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName());
|
|
||||||
return MAPPER.createArrayNode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split the CSV into individual variable paths, dropping blanks and whitespace.
|
|
||||||
List<String> paths = Arrays.stream(variablePathsCsv.split(","))
|
|
||||||
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
|
||||||
|
|
||||||
// Load the previous values (persisted as a JSON string variable at
|
|
||||||
// snapshotPath) to compare the current values against.
|
|
||||||
Map<String, JsonNode> oldSnapshot = loadSnapshot(currentScope, snapshotPath);
|
|
||||||
Map<String, JsonNode> newSnapshot = new HashMap<>();
|
|
||||||
ArrayNode changes = MAPPER.createArrayNode();
|
|
||||||
|
|
||||||
// For every tracked path: read the current value, normalize it so it
|
|
||||||
// compares consistently with the reloaded snapshot, and record a change
|
|
||||||
// entry whenever old != new.
|
|
||||||
for (String path : paths) {
|
|
||||||
JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, path)));
|
|
||||||
newSnapshot.put(path, newValue);
|
|
||||||
|
|
||||||
JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode());
|
|
||||||
if (!oldValue.equals(newValue)) {
|
|
||||||
ObjectNode change = MAPPER.createObjectNode();
|
|
||||||
change.put("path", path);
|
|
||||||
change.set("oldValue", oldValue);
|
|
||||||
change.set("newValue", newValue);
|
|
||||||
changes.add(change);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the current values as the reference snapshot for the next call.
|
|
||||||
saveSnapshot(currentScope, snapshotPath, newSnapshot);
|
|
||||||
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Path resolution
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads a value via a dot-notation path, e.g. "order.customer.name".
|
|
||||||
* The first segment names a Flowable variable; the remaining segments navigate
|
|
||||||
* into that value (maps, lists/arrays by index, JsonNodes, POJOs — see
|
|
||||||
* {@link #getNestedVariable}). Returns null when any segment cannot be resolved.
|
|
||||||
*/
|
|
||||||
private Object readVariableFromPath(VariableScope scope, String path) {
|
|
||||||
String[] segments = path.split("\\.", -1);
|
|
||||||
if (segments.length < 1) return null;
|
|
||||||
|
|
||||||
// A leading "root." switches to the root instance of the surrounding
|
|
||||||
// case/process hierarchy before resolving the variable.
|
|
||||||
int startIndex = 0;
|
|
||||||
if ("root".equals(segments[0])) {
|
|
||||||
if (segments.length < 2) return null;
|
|
||||||
startIndex = 1;
|
|
||||||
scope = getRootScope(scope);
|
|
||||||
}
|
|
||||||
if (scope == null) return null;
|
|
||||||
|
|
||||||
// Read the top-level variable, then walk the remaining segments into it.
|
|
||||||
Object currentValue = scope.getVariable(segments[startIndex]);
|
|
||||||
for (int i = startIndex + 1; i < segments.length; i++) {
|
|
||||||
if (currentValue == null) return null;
|
|
||||||
currentValue = getNestedVariable(currentValue, segments[i]);
|
|
||||||
}
|
|
||||||
return currentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a value via a dot-notation path (same syntax as
|
|
||||||
* {@link #readVariableFromPath}). A single-segment path sets a plain Flowable
|
|
||||||
* variable; a nested path mutates the container inside the top-level variable
|
|
||||||
* and writes that variable back so the engine persists the change.
|
|
||||||
*/
|
|
||||||
private void writeVariableToPath(VariableScope scope, String path, Object value) {
|
|
||||||
String[] segments = path.split("\\.", -1);
|
|
||||||
if (segments.length < 1) return;
|
|
||||||
|
|
||||||
// A leading "root." redirects the write to the root instance of the
|
|
||||||
// surrounding case/process hierarchy.
|
|
||||||
VariableScope targetScope;
|
|
||||||
String[] varSegments;
|
|
||||||
if ("root".equals(segments[0])) {
|
|
||||||
if (segments.length < 2) return;
|
|
||||||
targetScope = getRootScope(scope);
|
|
||||||
varSegments = Arrays.copyOfRange(segments, 1, segments.length);
|
|
||||||
} else {
|
|
||||||
targetScope = scope;
|
|
||||||
varSegments = segments;
|
|
||||||
}
|
|
||||||
if (targetScope == null) return;
|
|
||||||
|
|
||||||
if (varSegments.length == 1) {
|
|
||||||
targetScope.setVariable(varSegments[0], value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nested path: read the top-level variable, navigate to the parent node,
|
|
||||||
// mutate it in-place, then write the top-level variable back.
|
|
||||||
String topVar = varSegments[0];
|
|
||||||
Object topValue = targetScope.getVariable(topVar);
|
|
||||||
|
|
||||||
Object parent = topValue;
|
|
||||||
for (int i = 1; i < varSegments.length - 1; i++) {
|
|
||||||
if (parent == null) {
|
|
||||||
LOGGER.warn("varUtils.writeVariableToPath: null at '{}' in path '{}'", varSegments[i - 1], path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
parent = getNestedVariable(parent, varSegments[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!setNestedValue(parent, varSegments[varSegments.length - 1], value, path)) return;
|
|
||||||
targetScope.setVariable(topVar, topValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets one key on a mutable container (Map or ObjectNode). Anything else —
|
|
||||||
* including POJOs, which are read-only for this bean — is rejected with a
|
|
||||||
* warning so a bad path never breaks the calling expression.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private boolean setNestedValue(Object parent, String key, Object value, String path) {
|
|
||||||
if (parent instanceof Map map) {
|
|
||||||
map.put(key, value);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (parent instanceof ObjectNode on) {
|
|
||||||
on.set(key, toJson(value));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
LOGGER.warn("varUtils.writeVariableToPath: cannot set '{}' on {} in path '{}'",
|
|
||||||
key, parent == null ? "null" : parent.getClass().getName(), path);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Scope resolution
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Climbs from the given scope to the root VariableScope of the surrounding
|
|
||||||
* case/process hierarchy, following call-activity parents (BPMN) and
|
|
||||||
* parent/callback links (CMMN) across engine boundaries.
|
|
||||||
*/
|
|
||||||
private VariableScope getRootScope(VariableScope scope) {
|
|
||||||
if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId());
|
|
||||||
if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId());
|
|
||||||
if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the root scope starting from a BPMN process instance:
|
|
||||||
* 1. started by a call activity → recurse into the calling process instance,
|
|
||||||
* 2. started from a CMMN plan item (callback) → continue climbing in the case,
|
|
||||||
* 3. otherwise this process instance is itself the root.
|
|
||||||
* NOTE: the returned object is a detached query result used as VariableScope;
|
|
||||||
* its variable access only works because expression evaluation runs inside an
|
|
||||||
* active Flowable command context.
|
|
||||||
*/
|
|
||||||
private VariableScope findBpmnRootScope(String processInstanceId) {
|
|
||||||
if (processInstanceId == null || runtimeService == null) return null;
|
|
||||||
try {
|
|
||||||
Execution piExec = runtimeService.createExecutionQuery()
|
|
||||||
.executionId(processInstanceId).singleResult();
|
|
||||||
if (piExec != null && piExec.getSuperExecutionId() != null) {
|
|
||||||
Execution superExec = runtimeService.createExecutionQuery()
|
|
||||||
.executionId(piExec.getSuperExecutionId()).singleResult();
|
|
||||||
if (superExec != null)
|
|
||||||
return findBpmnRootScope(superExec.getProcessInstanceId());
|
|
||||||
}
|
|
||||||
ProcessInstance pi = runtimeService.createProcessInstanceQuery()
|
|
||||||
.processInstanceId(processInstanceId).singleResult();
|
|
||||||
if (pi != null && pi.getCallbackType() != null && pi.getCallbackId() != null
|
|
||||||
&& cmmnRuntimeService != null) {
|
|
||||||
PlanItemInstance planItem = cmmnRuntimeService.createPlanItemInstanceQuery()
|
|
||||||
.planItemInstanceId(pi.getCallbackId()).singleResult();
|
|
||||||
if (planItem != null)
|
|
||||||
return findCmmnRootScope(planItem.getCaseInstanceId());
|
|
||||||
}
|
|
||||||
return (ExecutionEntity) piExec; // root: ExecutionEntity implements VariableScope
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.debug("{}.findBpmnRootScope: BPMN root scope climb failed: {}", getClass().getName(), e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the root scope starting from a CMMN case instance:
|
|
||||||
* 1. sub-case → recurse into the parent case,
|
|
||||||
* 2. started from a BPMN call/task (callback) → continue climbing in the process,
|
|
||||||
* 3. otherwise this case instance is itself the root.
|
|
||||||
* Same detached-query-result caveat as {@link #findBpmnRootScope}.
|
|
||||||
*/
|
|
||||||
private VariableScope findCmmnRootScope(String caseInstanceId) {
|
|
||||||
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
|
|
||||||
try {
|
|
||||||
CaseInstance ci = cmmnRuntimeService.createCaseInstanceQuery()
|
|
||||||
.caseInstanceId(caseInstanceId).singleResult();
|
|
||||||
if (ci == null) return null;
|
|
||||||
if (ci.getParentId() != null)
|
|
||||||
return findCmmnRootScope(ci.getParentId());
|
|
||||||
if (ci.getCallbackType() != null && ci.getCallbackId() != null
|
|
||||||
&& runtimeService != null) {
|
|
||||||
Execution callbackExec = runtimeService.createExecutionQuery()
|
|
||||||
.executionId(ci.getCallbackId()).singleResult();
|
|
||||||
if (callbackExec != null)
|
|
||||||
return findBpmnRootScope(callbackExec.getProcessInstanceId());
|
|
||||||
}
|
|
||||||
return (CaseInstanceEntity) ci; // root: CaseInstanceEntity implements VariableScope
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.debug("{}.findCmmnRootScope: CMMN root scope climb failed: {}", getClass().getName(), e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves one path segment against a value: list/array index, map key,
|
|
||||||
* JsonNode field (scalars are unwrapped to plain Java values), and finally
|
|
||||||
* POJO access via getX()/isX() getters or a declared field.
|
|
||||||
*/
|
|
||||||
private static Object getNestedVariable(Object obj, String segment) {
|
|
||||||
// Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything.
|
|
||||||
if (segment == null || segment.isEmpty()) return null;
|
|
||||||
|
|
||||||
// Lists and arrays are addressed by numeric index, e.g. "items.0.name".
|
|
||||||
if (obj instanceof List<?> list) {
|
|
||||||
try {
|
|
||||||
int i = Integer.parseInt(segment);
|
|
||||||
return i >= 0 && i < list.size() ? list.get(i) : null;
|
|
||||||
} catch (NumberFormatException ignored) {}
|
|
||||||
}
|
|
||||||
if (obj instanceof Object[] arr) {
|
|
||||||
try {
|
|
||||||
int i = Integer.parseInt(segment);
|
|
||||||
return i >= 0 && i < arr.length ? arr[i] : null;
|
|
||||||
} catch (NumberFormatException ignored) {}
|
|
||||||
}
|
|
||||||
if (obj instanceof Map<?, ?> map) return map.get(segment);
|
|
||||||
|
|
||||||
// JsonNode: unwrap scalar fields to plain Java values so they compare and
|
|
||||||
// serialize the same way as values read from Map/POJO variables.
|
|
||||||
if (obj instanceof JsonNode jn) {
|
|
||||||
JsonNode node = jn.get(segment);
|
|
||||||
if (node == null || node.isNull()) return null;
|
|
||||||
if (node.isTextual()) return node.asText();
|
|
||||||
if (node.isBoolean()) return node.asBoolean();
|
|
||||||
if (node.isLong()) return node.asLong();
|
|
||||||
if (node.isInt()) return node.asInt();
|
|
||||||
if (node.isDouble()) return node.asDouble();
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for POJOs: try bean getters first, then a declared field
|
|
||||||
// (climbing the class hierarchy). Read-only — writes reject POJO parents.
|
|
||||||
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
|
|
||||||
try {
|
|
||||||
return obj.getClass().getMethod("get" + cap).invoke(obj);
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
try {
|
|
||||||
return obj.getClass().getMethod("is" + cap).invoke(obj);
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
try {
|
|
||||||
java.lang.reflect.Field f = findField(obj.getClass(), segment);
|
|
||||||
if (f != null) { f.setAccessible(true); return f.get(obj); }
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
LOGGER.warn("varUtils: cannot resolve '{}' on {}", segment, obj.getClass().getName());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Looks up a declared field by name, walking up the class hierarchy. */
|
|
||||||
private static java.lang.reflect.Field findField(Class<?> c, String name) {
|
|
||||||
while (c != null && c != Object.class) {
|
|
||||||
try { return c.getDeclaredField(name); }
|
|
||||||
catch (NoSuchFieldException e) { c = c.getSuperclass(); }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Snapshot persistence
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the previous-values snapshot: reads the variable at {@code path}
|
|
||||||
* (persisted as a JSON string by {@link #saveSnapshot}) and parses it back
|
|
||||||
* into a path → value-node map. Returns an empty map when there is no
|
|
||||||
* snapshot yet (first call) or it is unreadable.
|
|
||||||
*/
|
|
||||||
private Map<String, JsonNode> loadSnapshot(VariableScope scope, String path) {
|
|
||||||
Object raw = readVariableFromPath(scope, path);
|
|
||||||
if (raw == null) return new HashMap<>();
|
|
||||||
try {
|
|
||||||
String json = raw instanceof String s ? s : MAPPER.writeValueAsString(raw);
|
|
||||||
Map<String, Object> flat = MAPPER.readValue(json,
|
|
||||||
MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
|
|
||||||
Map<String, JsonNode> result = new HashMap<>();
|
|
||||||
flat.forEach((k, v) -> result.put(k, toJson(v)));
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.warn("varUtils: could not load snapshot at '{}': {}", path, e.getMessage());
|
|
||||||
return new HashMap<>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persists the snapshot map as a JSON string variable at {@code path}. */
|
|
||||||
private void saveSnapshot(VariableScope scope, String path, Map<String, JsonNode> snapshot) {
|
|
||||||
try {
|
|
||||||
writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot));
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.error("varUtils: could not save snapshot at '{}'", path, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Wraps any Java value into a JsonNode (null-safe). */
|
|
||||||
private static JsonNode toJson(Object value) {
|
|
||||||
if (value == null) return MAPPER.nullNode();
|
|
||||||
if (value instanceof JsonNode jn) return jn;
|
|
||||||
return MAPPER.valueToTree(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes and re-parses a node so its value types match what
|
|
||||||
* {@link #loadSnapshot} produces when reading the persisted snapshot back
|
|
||||||
* (e.g. a Long 5 read from a variable and an Integer 5 parsed from the
|
|
||||||
* snapshot JSON both become the same numeric node). Without this, unchanged
|
|
||||||
* numeric values would be reported as changes on every call.
|
|
||||||
*/
|
|
||||||
private static JsonNode normalize(JsonNode node) {
|
|
||||||
try {
|
|
||||||
return MAPPER.readTree(MAPPER.writeValueAsString(node));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,4 +42,3 @@ flowable.security.basic-auth.password=test
|
|||||||
flowable.mail.server.host=localhost
|
flowable.mail.server.host=localhost
|
||||||
flowable.mail.server.port=2525
|
flowable.mail.server.port=2525
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package com.customer.work.model;
|
package com.customer.work.model;
|
||||||
|
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellType;
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.FormulaEvaluator;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import com.flowable.audit.api.AuditService;
|
|||||||
import com.flowable.audit.api.runtime.AuditInstance;
|
import com.flowable.audit.api.runtime.AuditInstance;
|
||||||
import com.flowable.core.spring.security.SecurityUtils;
|
import com.flowable.core.spring.security.SecurityUtils;
|
||||||
import com.flowable.platform.service.task.CompleteFormRepresentation;
|
import com.flowable.platform.service.task.CompleteFormRepresentation;
|
||||||
import com.flowable.platform.service.task.PlatformTaskService;
|
|
||||||
import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse;
|
import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse;
|
||||||
import com.flowable.serviceregistry.api.runtime.ServiceRegistryRuntimeService;
|
import com.flowable.serviceregistry.api.runtime.ServiceRegistryRuntimeService;
|
||||||
import com.github.wnameless.json.flattener.JsonFlattener;
|
import com.github.wnameless.json.flattener.JsonFlattener;
|
||||||
import com.github.wnameless.json.unflattener.JsonUnflattener;
|
import com.github.wnameless.json.unflattener.JsonUnflattener;
|
||||||
|
import com.flowable.platform.service.task.PlatformTaskService;
|
||||||
import jakarta.mail.Address;
|
import jakarta.mail.Address;
|
||||||
import org.assertj.core.api.Assertions;
|
import org.assertj.core.api.Assertions;
|
||||||
import org.flowable.bpmn.model.*;
|
import org.flowable.bpmn.model.*;
|
||||||
@@ -39,14 +39,18 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StreamUtils;
|
import org.springframework.util.StreamUtils;
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.customer.work.model.test;
|
package com.customer.work.model.test;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import com.customer.work.model.EmailDto;
|
import com.customer.work.model.EmailDto;
|
||||||
import com.customer.work.model.FlowableModelTest;
|
import com.customer.work.model.FlowableModelTest;
|
||||||
import com.customer.work.model.FlowableModelTestUtils;
|
import com.customer.work.model.FlowableModelTestUtils;
|
||||||
import com.customer.work.model.RestRequestDto;
|
import com.customer.work.model.RestRequestDto;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse;
|
import com.flowable.serviceregistry.api.runtime.ServiceInvocationResultResponse;
|
||||||
import org.assertj.core.api.Assertions;
|
import org.assertj.core.api.Assertions;
|
||||||
import org.flowable.cmmn.api.runtime.CaseInstance;
|
import org.flowable.cmmn.api.runtime.CaseInstance;
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package com.customer.work.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test for the array tracking of {@link VarUtil#trackVars}. The
|
||||||
|
* parameterized tests run every scenario against both the local scope
|
||||||
|
* (prefix "") and the root scope (prefix "root."), the latter exercising the
|
||||||
|
* BPMN root-scope climb through the mocked RuntimeService of the base class.
|
||||||
|
*/
|
||||||
|
class VarUtilArrayTrackingTest extends VarUtilMockScopeTest {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Combination matrix: every scenario runs for local ("") and root ("root.")
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void scalarVariable(String prefix) {
|
||||||
|
vars(prefix).put("status", "a");
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status");
|
||||||
|
|
||||||
|
vars(prefix).put("status", "b");
|
||||||
|
ArrayNode changes = track(prefix + "status");
|
||||||
|
assertPaths(changes, prefix + "status");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("b");
|
||||||
|
|
||||||
|
assertThat(track(prefix + "status")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void nestedObjectField(String prefix) {
|
||||||
|
Map<String, Object> customer = new HashMap<>(Map.of("name", "n1"));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("customer", customer)));
|
||||||
|
assertPaths(track(prefix + "order.customer.name"), prefix + "order.customer.name");
|
||||||
|
|
||||||
|
customer.put("name", "n2");
|
||||||
|
ArrayNode changes = track(prefix + "order.customer.name");
|
||||||
|
assertPaths(changes, prefix + "order.customer.name");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("n2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareSimpleTypeArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a", "b"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status[1]");
|
||||||
|
|
||||||
|
status.set(0, "x");
|
||||||
|
ArrayNode changes = track(prefix + "status");
|
||||||
|
assertPaths(changes, prefix + "status[0]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitSimpleTypeArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a", "b"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
assertPaths(track(prefix + "status[]"), prefix + "status[0]", prefix + "status[1]");
|
||||||
|
|
||||||
|
status.set(1, "x");
|
||||||
|
assertPaths(track(prefix + "status[]"), prefix + "status[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareObjectArray(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = new ArrayList<>(List.of(
|
||||||
|
new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2"))));
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders"), prefix + "orders[0]", prefix + "orders[1]");
|
||||||
|
|
||||||
|
orders.get(1).put("name", "changed");
|
||||||
|
ArrayNode changes = track(prefix + "orders");
|
||||||
|
assertPaths(changes, prefix + "orders[1]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").get("name").asText()).isEqualTo("n2");
|
||||||
|
assertThat(changes.get(0).get("newValue").get("name").asText()).isEqualTo("changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void objectArrayField(String prefix) {
|
||||||
|
ArrayNode orders = MAPPER.createArrayNode();
|
||||||
|
orders.addObject().put("name", "n1");
|
||||||
|
orders.addObject().put("name", "n2");
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].name"),
|
||||||
|
prefix + "orders[0].name", prefix + "orders[1].name");
|
||||||
|
|
||||||
|
((ObjectNode) orders.get(0)).put("name", "changed");
|
||||||
|
ArrayNode changes = track(prefix + "orders[].name");
|
||||||
|
assertPaths(changes, prefix + "orders[0].name");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareArrayInNestedObject(String prefix) {
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>(List.of(item(1), item(2)));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("items", items)));
|
||||||
|
assertPaths(track(prefix + "order.items"),
|
||||||
|
prefix + "order.items[0]", prefix + "order.items[1]");
|
||||||
|
|
||||||
|
items.get(0).put("qty", 9);
|
||||||
|
assertPaths(track(prefix + "order.items"), prefix + "order.items[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitArrayInNestedObject(String prefix) {
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>(List.of(item(1), item(2)));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("items", items)));
|
||||||
|
assertPaths(track(prefix + "order.items[].qty"),
|
||||||
|
prefix + "order.items[0].qty", prefix + "order.items[1].qty");
|
||||||
|
|
||||||
|
items.get(1).put("qty", 5);
|
||||||
|
ArrayNode changes = track(prefix + "order.items[].qty");
|
||||||
|
assertPaths(changes, prefix + "order.items[1].qty");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void nestedArraysExplicit(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = nestedOrders();
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].items[].qty"),
|
||||||
|
prefix + "orders[0].items[0].qty", prefix + "orders[0].items[1].qty",
|
||||||
|
prefix + "orders[1].items[0].qty");
|
||||||
|
|
||||||
|
item(orders, 1, 0).put("qty", 99);
|
||||||
|
ArrayNode changes = track(prefix + "orders[].items[].qty");
|
||||||
|
assertPaths(changes, prefix + "orders[1].items[0].qty");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(3);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(99);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareTailAfterExplicitArray(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = nestedOrders();
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].items"),
|
||||||
|
prefix + "orders[0].items[0]", prefix + "orders[0].items[1]",
|
||||||
|
prefix + "orders[1].items[0]");
|
||||||
|
|
||||||
|
item(orders, 0, 1).put("qty", 7);
|
||||||
|
assertPaths(track(prefix + "orders[].items"), prefix + "orders[0].items[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareArrayOfArrays(String prefix) {
|
||||||
|
List<List<Integer>> matrix = new ArrayList<>(List.of(
|
||||||
|
new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3))));
|
||||||
|
vars(prefix).put("matrix", matrix);
|
||||||
|
assertPaths(track(prefix + "matrix"),
|
||||||
|
prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]");
|
||||||
|
|
||||||
|
matrix.get(0).set(1, 20);
|
||||||
|
ArrayNode changes = track(prefix + "matrix");
|
||||||
|
assertPaths(changes, prefix + "matrix[0][1]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitArrayOfArrays(String prefix) {
|
||||||
|
List<List<Integer>> matrix = new ArrayList<>(List.of(
|
||||||
|
new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3))));
|
||||||
|
vars(prefix).put("matrix", matrix);
|
||||||
|
assertPaths(track(prefix + "matrix[][]"),
|
||||||
|
prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]");
|
||||||
|
|
||||||
|
matrix.get(1).set(0, 30);
|
||||||
|
assertPaths(track(prefix + "matrix[][]"), prefix + "matrix[1][0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void growAndShrinkBareArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
track(prefix + "status");
|
||||||
|
|
||||||
|
status.add("b");
|
||||||
|
ArrayNode grown = track(prefix + "status");
|
||||||
|
assertPaths(grown, prefix + "status[1]");
|
||||||
|
assertThat(grown.get(0).get("oldValue").isNull()).isTrue();
|
||||||
|
assertThat(grown.get(0).get("newValue").asText()).isEqualTo("b");
|
||||||
|
|
||||||
|
status.remove(1);
|
||||||
|
ArrayNode shrunk = track(prefix + "status");
|
||||||
|
assertPaths(shrunk, prefix + "status[1]");
|
||||||
|
assertThat(shrunk.get(0).get("oldValue").asText()).isEqualTo("b");
|
||||||
|
assertThat(shrunk.get(0).get("newValue").isNull()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void shapeChangeBetweenScalarAndArray(String prefix) {
|
||||||
|
vars(prefix).put("status", "x");
|
||||||
|
track(prefix + "status");
|
||||||
|
|
||||||
|
// scalar -> array: the element appears, the scalar entry is retired
|
||||||
|
vars(prefix).put("status", new ArrayList<>(List.of("a")));
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status");
|
||||||
|
|
||||||
|
// array -> scalar: the scalar reappears, the element entry is retired
|
||||||
|
vars(prefix).put("status", "y");
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status", prefix + "status[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Local-only edge cases
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonArrayValueWithExplicitBracketsExpandsToNothing() {
|
||||||
|
localVars.put("status", "notAnArray");
|
||||||
|
assertThat(track("status[]")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyArrayTracksNothing() {
|
||||||
|
localVars.put("status", new ArrayList<>());
|
||||||
|
assertThat(track("status")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private ArrayNode track(String csv) {
|
||||||
|
return varUtil.trackVars(scope, "snap", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertPaths(ArrayNode changes, String... paths) {
|
||||||
|
assertThat(changes).extracting(c -> c.get("path").asText())
|
||||||
|
.containsExactlyInAnyOrder(paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package com.customer.work.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test for the null-safe var:-style expression functions of
|
||||||
|
* {@link VarUtil} (get, exists, isEmpty, equals, contains, comparisons,
|
||||||
|
* base64), focused on null safety in nested arrays.
|
||||||
|
*/
|
||||||
|
class VarUtilExpressionFunctionsTest extends VarUtilMockScopeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIsNullSafe() {
|
||||||
|
assertThat(varUtil.get(scope, "missing")).isNull();
|
||||||
|
assertThat(varUtil.get(scope, "missing.customer.name")).isNull();
|
||||||
|
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.get(scope, "orders[5].items[0].qty")).isNull(); // index out of bounds
|
||||||
|
assertThat(varUtil.get(scope, "orders[0].nope.qty")).isNull(); // missing field
|
||||||
|
assertThat(varUtil.get(scope, "orders[0].items[7]")).isNull(); // nested index out of bounds
|
||||||
|
assertThat(varUtil.get(null, "orders")).isNull(); // no scope
|
||||||
|
assertThat(varUtil.get(scope, null)).isNull(); // no path
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getReadsNestedArrays() {
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.get(scope, "orders[1].items[0].qty")).isEqualTo(3);
|
||||||
|
|
||||||
|
rootVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.get(scope, "root.orders[0].items[1].qty")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectsArraysWithBrackets() {
|
||||||
|
List<Map<String, Object>> orders = new ArrayList<>(List.of(
|
||||||
|
new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2"))));
|
||||||
|
localVars.put("orders", orders);
|
||||||
|
assertThat(varUtil.get(scope, "orders[].name")).isEqualTo(List.of("n1", "n2"));
|
||||||
|
|
||||||
|
localVars.put("nested", nestedOrders());
|
||||||
|
assertThat(varUtil.get(scope, "nested[].items[].qty")).isEqualTo(List.of(1, 2, 3));
|
||||||
|
|
||||||
|
// bare array names are NOT projected — the array itself is returned
|
||||||
|
assertThat(varUtil.get(scope, "orders")).isSameAs(orders);
|
||||||
|
|
||||||
|
// missing fields yield null entries
|
||||||
|
orders.get(1).remove("name");
|
||||||
|
assertThat(varUtil.get(scope, "orders[].name")).isEqualTo(Arrays.asList("n1", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrDefaultFallsBack() {
|
||||||
|
assertThat(varUtil.getOrDefault(scope, "missing", "dflt")).isEqualTo("dflt");
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.getOrDefault(scope, "orders[5].items[0].qty", 0)).isEqualTo(0);
|
||||||
|
assertThat(varUtil.getOrDefault(scope, "orders[0].items[0].qty", 0)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsChecksResolvability() {
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.exists(scope, "orders[0].items[1].qty")).isTrue();
|
||||||
|
assertThat(varUtil.exists(scope, "orders[0].items[9].qty")).isFalse();
|
||||||
|
assertThat(varUtil.exists(scope, "missing")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isEmptyHandlesAllContainerTypes() {
|
||||||
|
assertThat(varUtil.isEmpty(scope, "missing")).isTrue();
|
||||||
|
localVars.put("blank", "");
|
||||||
|
localVars.put("text", "x");
|
||||||
|
localVars.put("emptyList", new ArrayList<>());
|
||||||
|
localVars.put("emptyJson", MAPPER.createArrayNode());
|
||||||
|
assertThat(varUtil.isEmpty(scope, "blank")).isTrue();
|
||||||
|
assertThat(varUtil.isEmpty(scope, "text")).isFalse();
|
||||||
|
assertThat(varUtil.isEmpty(scope, "emptyList")).isTrue();
|
||||||
|
assertThat(varUtil.isEmpty(scope, "emptyJson")).isTrue();
|
||||||
|
assertThat(varUtil.isNotEmpty(scope, "text")).isTrue();
|
||||||
|
|
||||||
|
// a projection over a missing array is an empty list
|
||||||
|
assertThat(varUtil.isEmpty(scope, "missing[].name")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equalsNormalizesTypes() {
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 2)).isTrue();
|
||||||
|
assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 2L)).isTrue(); // Long vs Integer
|
||||||
|
assertThat(varUtil.equals(scope, "orders[0].items[1].qty", 3)).isFalse();
|
||||||
|
assertThat(varUtil.equals(scope, "orders[9].items[0].qty", null)).isTrue(); // unresolvable equals null
|
||||||
|
assertThat(varUtil.notEquals(scope, "orders[0].items[1].qty", 3)).isTrue();
|
||||||
|
|
||||||
|
ArrayNode json = MAPPER.createArrayNode();
|
||||||
|
json.addObject().put("name", "n1");
|
||||||
|
localVars.put("json", json);
|
||||||
|
assertThat(varUtil.equals(scope, "json[0].name", "n1")).isTrue(); // JsonNode vs String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void containsWorksOnStringsArraysAndProjections() {
|
||||||
|
localVars.put("greeting", "hello world");
|
||||||
|
assertThat(varUtil.contains(scope, "greeting", "hello", "world")).isTrue();
|
||||||
|
assertThat(varUtil.contains(scope, "greeting", "hello", "mars")).isFalse();
|
||||||
|
assertThat(varUtil.containsAny(scope, "greeting", "mars", "world")).isTrue();
|
||||||
|
assertThat(varUtil.containsAny(scope, "greeting", "mars", "venus")).isFalse();
|
||||||
|
|
||||||
|
localVars.put("tags", new ArrayList<>(List.of("a", "b")));
|
||||||
|
assertThat(varUtil.contains(scope, "tags", "a", "b")).isTrue();
|
||||||
|
assertThat(varUtil.contains(scope, "tags", "a", "c")).isFalse();
|
||||||
|
|
||||||
|
localVars.put("nums", MAPPER.createArrayNode().add(1).add(2));
|
||||||
|
assertThat(varUtil.contains(scope, "nums", 1, 2)).isTrue(); // JSON array vs Integer
|
||||||
|
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.contains(scope, "orders[].items[].qty", 3)).isTrue(); // projection
|
||||||
|
assertThat(varUtil.contains(scope, "orders[].items[].qty", 4)).isFalse();
|
||||||
|
|
||||||
|
assertThat(varUtil.contains(scope, "missing", "x")).isFalse(); // null-safe
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void numericComparisonsAreNullSafe() {
|
||||||
|
localVars.put("orders", nestedOrders());
|
||||||
|
assertThat(varUtil.lowerThan(scope, "orders[0].items[0].qty", 2)).isTrue(); // 1 < 2
|
||||||
|
assertThat(varUtil.lowerThan(scope, "orders[0].items[0].qty", 1)).isFalse();
|
||||||
|
assertThat(varUtil.lowerThanOrEquals(scope, "orders[0].items[0].qty", 1)).isTrue();
|
||||||
|
assertThat(varUtil.greaterThan(scope, "orders[1].items[0].qty", 2)).isTrue(); // 3 > 2
|
||||||
|
assertThat(varUtil.greaterThanOrEquals(scope, "orders[1].items[0].qty", 3)).isTrue();
|
||||||
|
|
||||||
|
assertThat(varUtil.greaterThan(scope, "orders[9].items[0].qty", 2)).isFalse(); // null -> false
|
||||||
|
localVars.put("text", "abc");
|
||||||
|
assertThat(varUtil.lowerThan(scope, "text", 2)).isFalse(); // non-numeric -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void base64EncodesStringsAndBytes() {
|
||||||
|
localVars.put("text", "hello");
|
||||||
|
assertThat(varUtil.base64(scope, "text")).isEqualTo("aGVsbG8=");
|
||||||
|
localVars.put("bytes", "hello".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertThat(varUtil.base64(scope, "bytes")).isEqualTo("aGVsbG8=");
|
||||||
|
assertThat(varUtil.base64(scope, "missing")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.customer.work.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.flowable.engine.RuntimeService;
|
||||||
|
import org.flowable.engine.delegate.DelegateExecution;
|
||||||
|
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared scaffolding for varutil unit tests: a map-backed local scope
|
||||||
|
* (DelegateExecution) plus a mocked BPMN root scope resolved through a mocked
|
||||||
|
* RuntimeService, so "root." paths work without an engine.
|
||||||
|
*/
|
||||||
|
abstract class VarUtilMockScopeTest {
|
||||||
|
|
||||||
|
protected static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
protected final VarUtil varUtil = new VarUtil();
|
||||||
|
protected final Map<String, Object> localVars = new HashMap<>();
|
||||||
|
protected final Map<String, Object> rootVars = new HashMap<>();
|
||||||
|
protected DelegateExecution scope;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUpScopes() {
|
||||||
|
// Local scope: a DelegateExecution whose variables live in localVars.
|
||||||
|
scope = mock(DelegateExecution.class);
|
||||||
|
when(scope.getVariable(anyString())).thenAnswer(inv -> localVars.get(inv.<String>getArgument(0)));
|
||||||
|
doAnswer(inv -> localVars.put(inv.getArgument(0), inv.getArgument(1)))
|
||||||
|
.when(scope).setVariable(anyString(), any());
|
||||||
|
when(scope.getProcessInstanceId()).thenReturn("pi1");
|
||||||
|
|
||||||
|
// Root scope: the process-instance execution that findBpmnRootScope
|
||||||
|
// resolves for "root." paths, backed by rootVars.
|
||||||
|
ExecutionEntity rootExec = mock(ExecutionEntity.class);
|
||||||
|
when(rootExec.getVariable(anyString())).thenAnswer(inv -> rootVars.get(inv.<String>getArgument(0)));
|
||||||
|
doAnswer(inv -> rootVars.put(inv.getArgument(0), inv.getArgument(1)))
|
||||||
|
.when(rootExec).setVariable(anyString(), any());
|
||||||
|
|
||||||
|
RuntimeService runtimeService = mock(RuntimeService.class, RETURNS_DEEP_STUBS);
|
||||||
|
when(runtimeService.createExecutionQuery().executionId("pi1").singleResult()).thenReturn(rootExec);
|
||||||
|
when(runtimeService.createProcessInstanceQuery().processInstanceId("pi1").singleResult()).thenReturn(null);
|
||||||
|
ReflectionTestUtils.setField(varUtil, "runtimeService", runtimeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variable map for a path prefix: "" -> local scope, "root." -> root scope. */
|
||||||
|
protected Map<String, Object> vars(String prefix) {
|
||||||
|
return prefix.isEmpty() ? localVars : rootVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [{items:[{qty:1},{qty:2}]}, {items:[{qty:3}]}] as mutable lists/maps. */
|
||||||
|
protected static List<Map<String, Object>> nestedOrders() {
|
||||||
|
List<Map<String, Object>> orders = new ArrayList<>();
|
||||||
|
orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(1), item(2))))));
|
||||||
|
orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(3))))));
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static Map<String, Object> item(int qty) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("qty", qty);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
protected static Map<String, Object> item(List<Map<String, Object>> orders, int order, int item) {
|
||||||
|
return ((List<Map<String, Object>>) orders.get(order).get("items")).get(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package com.customer.work.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.flowable.engine.RuntimeService;
|
||||||
|
import org.flowable.engine.delegate.DelegateExecution;
|
||||||
|
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test for the array tracking of {@link VarUtil#trackVars}, using
|
||||||
|
* map-backed mock scopes so no engine is required. The parameterized tests run
|
||||||
|
* every scenario against both the local scope (prefix "") and the root scope
|
||||||
|
* (prefix "root."), the latter exercising the BPMN root-scope climb through a
|
||||||
|
* mocked RuntimeService.
|
||||||
|
*/
|
||||||
|
class VarUtilTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final VarUtil varUtil = new VarUtil();
|
||||||
|
private final Map<String, Object> localVars = new HashMap<>();
|
||||||
|
private final Map<String, Object> rootVars = new HashMap<>();
|
||||||
|
private DelegateExecution scope;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// Local scope: a DelegateExecution whose variables live in localVars.
|
||||||
|
scope = mock(DelegateExecution.class);
|
||||||
|
when(scope.getVariable(anyString())).thenAnswer(inv -> localVars.get(inv.<String>getArgument(0)));
|
||||||
|
doAnswer(inv -> localVars.put(inv.getArgument(0), inv.getArgument(1)))
|
||||||
|
.when(scope).setVariable(anyString(), any());
|
||||||
|
when(scope.getProcessInstanceId()).thenReturn("pi1");
|
||||||
|
|
||||||
|
// Root scope: the process-instance execution that findBpmnRootScope
|
||||||
|
// resolves for "root." paths, backed by rootVars.
|
||||||
|
ExecutionEntity rootExec = mock(ExecutionEntity.class);
|
||||||
|
when(rootExec.getVariable(anyString())).thenAnswer(inv -> rootVars.get(inv.<String>getArgument(0)));
|
||||||
|
doAnswer(inv -> rootVars.put(inv.getArgument(0), inv.getArgument(1)))
|
||||||
|
.when(rootExec).setVariable(anyString(), any());
|
||||||
|
|
||||||
|
RuntimeService runtimeService = mock(RuntimeService.class, RETURNS_DEEP_STUBS);
|
||||||
|
when(runtimeService.createExecutionQuery().executionId("pi1").singleResult()).thenReturn(rootExec);
|
||||||
|
when(runtimeService.createProcessInstanceQuery().processInstanceId("pi1").singleResult()).thenReturn(null);
|
||||||
|
ReflectionTestUtils.setField(varUtil, "runtimeService", runtimeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Combination matrix: every scenario runs for local ("") and root ("root.")
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void scalarVariable(String prefix) {
|
||||||
|
vars(prefix).put("status", "a");
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status");
|
||||||
|
|
||||||
|
vars(prefix).put("status", "b");
|
||||||
|
ArrayNode changes = track(prefix + "status");
|
||||||
|
assertPaths(changes, prefix + "status");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("b");
|
||||||
|
|
||||||
|
assertThat(track(prefix + "status")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void nestedObjectField(String prefix) {
|
||||||
|
Map<String, Object> customer = new HashMap<>(Map.of("name", "n1"));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("customer", customer)));
|
||||||
|
assertPaths(track(prefix + "order.customer.name"), prefix + "order.customer.name");
|
||||||
|
|
||||||
|
customer.put("name", "n2");
|
||||||
|
ArrayNode changes = track(prefix + "order.customer.name");
|
||||||
|
assertPaths(changes, prefix + "order.customer.name");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("n2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareSimpleTypeArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a", "b"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status[1]");
|
||||||
|
|
||||||
|
status.set(0, "x");
|
||||||
|
ArrayNode changes = track(prefix + "status");
|
||||||
|
assertPaths(changes, prefix + "status[0]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("a");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitSimpleTypeArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a", "b"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
assertPaths(track(prefix + "status[]"), prefix + "status[0]", prefix + "status[1]");
|
||||||
|
|
||||||
|
status.set(1, "x");
|
||||||
|
assertPaths(track(prefix + "status[]"), prefix + "status[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareObjectArray(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = new ArrayList<>(List.of(
|
||||||
|
new HashMap<>(Map.of("name", "n1")), new HashMap<>(Map.of("name", "n2"))));
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders"), prefix + "orders[0]", prefix + "orders[1]");
|
||||||
|
|
||||||
|
orders.get(1).put("name", "changed");
|
||||||
|
ArrayNode changes = track(prefix + "orders");
|
||||||
|
assertPaths(changes, prefix + "orders[1]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").get("name").asText()).isEqualTo("n2");
|
||||||
|
assertThat(changes.get(0).get("newValue").get("name").asText()).isEqualTo("changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void objectArrayField(String prefix) {
|
||||||
|
ArrayNode orders = MAPPER.createArrayNode();
|
||||||
|
orders.addObject().put("name", "n1");
|
||||||
|
orders.addObject().put("name", "n2");
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].name"),
|
||||||
|
prefix + "orders[0].name", prefix + "orders[1].name");
|
||||||
|
|
||||||
|
((ObjectNode) orders.get(0)).put("name", "changed");
|
||||||
|
ArrayNode changes = track(prefix + "orders[].name");
|
||||||
|
assertPaths(changes, prefix + "orders[0].name");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asText()).isEqualTo("n1");
|
||||||
|
assertThat(changes.get(0).get("newValue").asText()).isEqualTo("changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareArrayInNestedObject(String prefix) {
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>(List.of(item(1), item(2)));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("items", items)));
|
||||||
|
assertPaths(track(prefix + "order.items"),
|
||||||
|
prefix + "order.items[0]", prefix + "order.items[1]");
|
||||||
|
|
||||||
|
items.get(0).put("qty", 9);
|
||||||
|
assertPaths(track(prefix + "order.items"), prefix + "order.items[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitArrayInNestedObject(String prefix) {
|
||||||
|
List<Map<String, Object>> items = new ArrayList<>(List.of(item(1), item(2)));
|
||||||
|
vars(prefix).put("order", new HashMap<>(Map.of("items", items)));
|
||||||
|
assertPaths(track(prefix + "order.items[].qty"),
|
||||||
|
prefix + "order.items[0].qty", prefix + "order.items[1].qty");
|
||||||
|
|
||||||
|
items.get(1).put("qty", 5);
|
||||||
|
ArrayNode changes = track(prefix + "order.items[].qty");
|
||||||
|
assertPaths(changes, prefix + "order.items[1].qty");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void nestedArraysExplicit(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = nestedOrders();
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].items[].qty"),
|
||||||
|
prefix + "orders[0].items[0].qty", prefix + "orders[0].items[1].qty",
|
||||||
|
prefix + "orders[1].items[0].qty");
|
||||||
|
|
||||||
|
item(orders, 1, 0).put("qty", 99);
|
||||||
|
ArrayNode changes = track(prefix + "orders[].items[].qty");
|
||||||
|
assertPaths(changes, prefix + "orders[1].items[0].qty");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(3);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(99);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareTailAfterExplicitArray(String prefix) {
|
||||||
|
List<Map<String, Object>> orders = nestedOrders();
|
||||||
|
vars(prefix).put("orders", orders);
|
||||||
|
assertPaths(track(prefix + "orders[].items"),
|
||||||
|
prefix + "orders[0].items[0]", prefix + "orders[0].items[1]",
|
||||||
|
prefix + "orders[1].items[0]");
|
||||||
|
|
||||||
|
item(orders, 0, 1).put("qty", 7);
|
||||||
|
assertPaths(track(prefix + "orders[].items"), prefix + "orders[0].items[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void bareArrayOfArrays(String prefix) {
|
||||||
|
List<List<Integer>> matrix = new ArrayList<>(List.of(
|
||||||
|
new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3))));
|
||||||
|
vars(prefix).put("matrix", matrix);
|
||||||
|
assertPaths(track(prefix + "matrix"),
|
||||||
|
prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]");
|
||||||
|
|
||||||
|
matrix.get(0).set(1, 20);
|
||||||
|
ArrayNode changes = track(prefix + "matrix");
|
||||||
|
assertPaths(changes, prefix + "matrix[0][1]");
|
||||||
|
assertThat(changes.get(0).get("oldValue").asInt()).isEqualTo(2);
|
||||||
|
assertThat(changes.get(0).get("newValue").asInt()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void explicitArrayOfArrays(String prefix) {
|
||||||
|
List<List<Integer>> matrix = new ArrayList<>(List.of(
|
||||||
|
new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(3))));
|
||||||
|
vars(prefix).put("matrix", matrix);
|
||||||
|
assertPaths(track(prefix + "matrix[][]"),
|
||||||
|
prefix + "matrix[0][0]", prefix + "matrix[0][1]", prefix + "matrix[1][0]");
|
||||||
|
|
||||||
|
matrix.get(1).set(0, 30);
|
||||||
|
assertPaths(track(prefix + "matrix[][]"), prefix + "matrix[1][0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void growAndShrinkBareArray(String prefix) {
|
||||||
|
List<String> status = new ArrayList<>(List.of("a"));
|
||||||
|
vars(prefix).put("status", status);
|
||||||
|
track(prefix + "status");
|
||||||
|
|
||||||
|
status.add("b");
|
||||||
|
ArrayNode grown = track(prefix + "status");
|
||||||
|
assertPaths(grown, prefix + "status[1]");
|
||||||
|
assertThat(grown.get(0).get("oldValue").isNull()).isTrue();
|
||||||
|
assertThat(grown.get(0).get("newValue").asText()).isEqualTo("b");
|
||||||
|
|
||||||
|
status.remove(1);
|
||||||
|
ArrayNode shrunk = track(prefix + "status");
|
||||||
|
assertPaths(shrunk, prefix + "status[1]");
|
||||||
|
assertThat(shrunk.get(0).get("oldValue").asText()).isEqualTo("b");
|
||||||
|
assertThat(shrunk.get(0).get("newValue").isNull()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"", "root."})
|
||||||
|
void shapeChangeBetweenScalarAndArray(String prefix) {
|
||||||
|
vars(prefix).put("status", "x");
|
||||||
|
track(prefix + "status");
|
||||||
|
|
||||||
|
// scalar -> array: the element appears, the scalar entry is retired
|
||||||
|
vars(prefix).put("status", new ArrayList<>(List.of("a")));
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status[0]", prefix + "status");
|
||||||
|
|
||||||
|
// array -> scalar: the scalar reappears, the element entry is retired
|
||||||
|
vars(prefix).put("status", "y");
|
||||||
|
assertPaths(track(prefix + "status"), prefix + "status", prefix + "status[0]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Local-only edge cases
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonArrayValueWithExplicitBracketsExpandsToNothing() {
|
||||||
|
localVars.put("status", "notAnArray");
|
||||||
|
assertThat(track("status[]")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyArrayTracksNothing() {
|
||||||
|
localVars.put("status", new ArrayList<>());
|
||||||
|
assertThat(track("status")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Map<String, Object> vars(String prefix) {
|
||||||
|
return prefix.isEmpty() ? localVars : rootVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArrayNode track(String csv) {
|
||||||
|
return varUtil.trackVars(scope, "snap", csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertPaths(ArrayNode changes, String... paths) {
|
||||||
|
assertThat(changes).extracting(c -> c.get("path").asText())
|
||||||
|
.containsExactlyInAnyOrder(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [{items:[{qty:1},{qty:2}]}, {items:[{qty:3}]}] as mutable lists/maps. */
|
||||||
|
private static List<Map<String, Object>> nestedOrders() {
|
||||||
|
List<Map<String, Object>> orders = new ArrayList<>();
|
||||||
|
orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(1), item(2))))));
|
||||||
|
orders.add(new HashMap<>(Map.of("items", new ArrayList<>(List.of(item(3))))));
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> item(int qty) {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("qty", qty);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static Map<String, Object> item(List<Map<String, Object>> orders, int order, int item) {
|
||||||
|
return ((List<Map<String, Object>>) orders.get(order).get("items")).get(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ management.elastic.metrics.export.enabled=false
|
|||||||
|
|
||||||
# Set debug level in tests
|
# Set debug level in tests
|
||||||
logging.level.com.flowable=INFO
|
logging.level.com.flowable=INFO
|
||||||
logging.level.com.flowable.local.work.model.FlowableModelTestUtils=INFO
|
logging.level.com.customer.work.model.FlowableModelTestUtils=INFO
|
||||||
|
|
||||||
# Disable the timeout process in the tests
|
# Disable the timeout process in the tests
|
||||||
flowable.external-system.wechat.timeout.process-definition-key=
|
flowable.external-system.wechat.timeout.process-definition-key=
|
||||||
@@ -22,3 +22,15 @@ flowable.app.resource-location=classpath*:/test-auto-deploy-apps/
|
|||||||
# Email
|
# Email
|
||||||
flowable.mail.server.host=localhost
|
flowable.mail.server.host=localhost
|
||||||
flowable.mail.server.port=3025
|
flowable.mail.server.port=3025
|
||||||
|
|
||||||
|
# REST: incoming calls (simulated against the Flowable REST API)
|
||||||
|
test.rest.in.base-url=http://localhost:8105
|
||||||
|
test.rest.in.username=admin
|
||||||
|
test.rest.in.password=test
|
||||||
|
|
||||||
|
# REST: outgoing calls from models go to the test REST server
|
||||||
|
# (service models resolve ${propertyConfigurationService.getProperty('customer.serverUrl', ...)})
|
||||||
|
customer.serverUrl=http://localhost:18085
|
||||||
|
test.rest.out.base-url=${customer.serverUrl}
|
||||||
|
test.rest.out.username=admin
|
||||||
|
test.rest.out.password=test
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user