diff --git a/customer-work/docs/model-tests.md b/customer-work/docs/model-tests.md new file mode 100644 index 0000000..5bd80b2 --- /dev/null +++ b/customer-work/docs/model-tests.md @@ -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//`, where `` 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 _T] → Call Activity → [Process under test ] +``` + +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 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//` 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 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 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.` | Variable on the root process (like `__ROOT` in JSON) | +| `in.` | In-mapping into the model (like `__IN`) | +| `out.` | 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.` | Expected value of variable `` 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 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 p002ExcelTestData2() { + return flowableModelTest.getObjArgumentsFromExcel("model/test/P002/p002Test.xlsx"); +} + +@ParameterizedTest +@MethodSource("p002ExcelTestData2") +public void p002ExcelTest2(String path, Map argument) { + Map 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 ` | A top-level field outside `__ROOT` / `__IN` / `__OUT` in a JSON parameter file — move it into the right block | +| `Resource not found: ` | Path is relative to `src/test/resources` and is loaded from the classpath — check spelling and location | +| `Column 'id' missing in row of ` | 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 | diff --git a/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java b/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java index f304862..e36ea8f 100644 --- a/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java +++ b/customer-work/src/main/java/com/customer/work/SecurityHttpBasicConfiguration.java @@ -30,7 +30,7 @@ public class SecurityHttpBasicConfiguration { @Order(10) public SecurityFilterChain basicDefaultSecurity(HttpSecurity http, ObjectProvider httpSecurityCustomizers) throws Exception { for (FlowableHttpSecurityCustomizer customizer : httpSecurityCustomizers.orderedStream() - .toList()) { + .collect(Collectors.toList())) { customizer.customize(http); } diff --git a/customer-work/src/main/java/com/customer/work/service/VarUtil.java b/customer-work/src/main/java/com/customer/work/service/VarUtil.java new file mode 100644 index 0000000..80fb791 --- /dev/null +++ b/customer-work/src/main/java/com/customer/work/service/VarUtil.java @@ -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 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 oldSnapshot = loadSnapshot(currentScope, snapshotPath); + Map 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 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 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 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 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 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 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 flat = MAPPER.readValue(json, + MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); + Map 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 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; + } + } +} diff --git a/customer-work/src/main/java/com/customer/work/service/VarUtils.java b/customer-work/src/main/java/com/customer/work/service/VarUtils.java deleted file mode 100644 index 182bce8..0000000 --- a/customer-work/src/main/java/com/customer/work/service/VarUtils.java +++ /dev/null @@ -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 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 oldSnapshot = loadSnapshot(currentScope, snapshotPath); - Map 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 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 flat = MAPPER.readValue(json, - MAPPER.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class)); - Map 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 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; - } - } -} diff --git a/customer-work/src/main/resources/application.properties b/customer-work/src/main/resources/application.properties index 7d617da..50d2932 100644 --- a/customer-work/src/main/resources/application.properties +++ b/customer-work/src/main/resources/application.properties @@ -42,4 +42,3 @@ flowable.security.basic-auth.password=test flowable.mail.server.host=localhost flowable.mail.server.port=2525 - diff --git a/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java b/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java index 38a6ef1..ae9762b 100644 --- a/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java +++ b/customer-work/src/test/java/com/customer/work/model/FlowableExcelParser.java @@ -1,6 +1,12 @@ 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.springframework.stereotype.Component; diff --git a/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java b/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java index b890b11..e868374 100644 --- a/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java +++ b/customer-work/src/test/java/com/customer/work/model/FlowableModelTestUtils.java @@ -11,11 +11,11 @@ import com.flowable.audit.api.AuditService; import com.flowable.audit.api.runtime.AuditInstance; import com.flowable.core.spring.security.SecurityUtils; 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.ServiceRegistryRuntimeService; import com.github.wnameless.json.flattener.JsonFlattener; import com.github.wnameless.json.unflattener.JsonUnflattener; +import com.flowable.platform.service.task.PlatformTaskService; import jakarta.mail.Address; import org.assertj.core.api.Assertions; import org.flowable.bpmn.model.*; @@ -39,14 +39,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; 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.util.*; import java.util.stream.Collectors; diff --git a/customer-work/src/test/java/com/customer/work/model/TestRestServer.java b/customer-work/src/test/java/com/customer/work/model/TestRestServer.java index ff854fc..931af3e 100644 --- a/customer-work/src/test/java/com/customer/work/model/TestRestServer.java +++ b/customer-work/src/test/java/com/customer/work/model/TestRestServer.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** diff --git a/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java b/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java index 52e3fe4..3c3c9ed 100644 --- a/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java +++ b/customer-work/src/test/java/com/customer/work/model/test/ModelTest.java @@ -1,11 +1,11 @@ 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.FlowableModelTest; import com.customer.work.model.FlowableModelTestUtils; 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 org.assertj.core.api.Assertions; import org.flowable.cmmn.api.runtime.CaseInstance; diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java new file mode 100644 index 0000000..e766866 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilArrayTrackingTest.java @@ -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 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 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 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> 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> 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> 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> 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> 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> 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> 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 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); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java new file mode 100644 index 0000000..791b259 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilExpressionFunctionsTest.java @@ -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> 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(); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java new file mode 100644 index 0000000..449a6e3 --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilMockScopeTest.java @@ -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 localVars = new HashMap<>(); + protected final Map 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.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.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 vars(String prefix) { + return prefix.isEmpty() ? localVars : rootVars; + } + + /** [{items:[{qty:1},{qty:2}]}, {items:[{qty:3}]}] as mutable lists/maps. */ + protected static List> nestedOrders() { + List> 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 item(int qty) { + Map m = new HashMap<>(); + m.put("qty", qty); + return m; + } + + @SuppressWarnings("unchecked") + protected static Map item(List> orders, int order, int item) { + return ((List>) orders.get(order).get("items")).get(item); + } +} diff --git a/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java b/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java new file mode 100644 index 0000000..3ac95fe --- /dev/null +++ b/customer-work/src/test/java/com/customer/work/service/VarUtilTest.java @@ -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 localVars = new HashMap<>(); + private final Map 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.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.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 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 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 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> 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> 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> 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> 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> 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> 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> 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 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 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> nestedOrders() { + List> 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 item(int qty) { + Map m = new HashMap<>(); + m.put("qty", qty); + return m; + } + + @SuppressWarnings("unchecked") + private static Map item(List> orders, int order, int item) { + return ((List>) orders.get(order).get("items")).get(item); + } +} diff --git a/customer-work/src/test/resources/application.properties b/customer-work/src/test/resources/application.properties index 50bda6c..c888e3d 100644 --- a/customer-work/src/test/resources/application.properties +++ b/customer-work/src/test/resources/application.properties @@ -11,7 +11,7 @@ management.elastic.metrics.export.enabled=false # Set debug level in tests 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 flowable.external-system.wechat.timeout.process-definition-key= @@ -21,4 +21,16 @@ flowable.app.resource-location=classpath*:/test-auto-deploy-apps/ # Email flowable.mail.server.host=localhost -flowable.mail.server.port=3025 \ No newline at end of file +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 \ No newline at end of file diff --git a/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip b/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip index f1070c2..a0d36df 100644 Binary files a/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip and b/customer-work/src/test/resources/test-auto-deploy-apps/TST_APP.zip differ diff --git a/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip b/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip index 21cb2ac..9c3a899 100644 Binary files a/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip and b/customer-work/src/test/resources/test-auto-deploy-apps/trackVarsApp.zip differ