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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .conductor/settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ archive = "docker compose -f stack/docker-compose.yml down -v || true"

# Bruno API smoke tests against a running local stack.
[scripts.run.smoke]
command = "cd bruno && npx --yes @usebruno/cli run . --env local -r"
command = "cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -r"
icon = "flask"

# Playwright browser end-to-end tests against a running local stack.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pre-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ jobs:

- name: Run Bruno scenarios
working-directory: bruno
run: npx --yes @usebruno/cli run . --env local -r
run: npx --yes @usebruno/cli@4.0.0 run . --env local -r

- name: Dump application log on failure
if: failure()
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ npm --prefix frontend run dev # UI on :5173 (proxies /api
| Regenerate + verify the OpenAPI contract | `./gradlew :service:app:test --tests "io.miragon.blueprint.openapi.OpenApiSpecExportTest"` then `git diff --exit-code openapi/openapi.json` |
| Frontend everything | `npm --prefix frontend run verify` |
| Regenerate the API client | `npm --prefix frontend run api:generate` (check: `api:check`) |
| API scenarios (running stack) | `cd bruno && npx --yes @usebruno/cli run . --env local -r` |
| API scenarios (running stack) | `cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -r` |
| Browser e2e (running stack) | `npm --prefix frontend run e2e` |
| BPMN lint | `npm run lint:bpmn` |
| Backend OCI image · full-stack run | `./gradlew :service:app:bootBuildImage` · `docker compose -f stack/docker-compose.full.yml up` — [ADR-0014](docs/adr/0014-build-and-deployment-approach.md), CONTRIBUTING "Run it in containers" |
Expand Down
4 changes: 2 additions & 2 deletions bruno/01-happy-path/04-sign-contract.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ post {
}

script:pre-request {
// Wait for the async job executor to drive the process to the contract-signature wait state.
await new Promise((resolve) => setTimeout(resolve, 2500));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null);
}

assert {
Expand Down
6 changes: 3 additions & 3 deletions bruno/01-happy-path/05-report-handover.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ post {
}

script:pre-request {
// Wait for the async continuations (order bike + issue insurance, then join) to reach the
// "handover reported" wait state.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id,
(b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null);
}

assert {
Expand Down
6 changes: 4 additions & 2 deletions bruno/01-happy-path/06-get-withdrawal-timer.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ get {
}

script:pre-request {
// Wait for the handover message's async continuation to reach the withdrawal-period timer.
await new Promise((resolve) => setTimeout(resolve, 2500));
const pid = bru.getVar('processInstanceId');
await pollEngine(
'/job?processInstanceId=' + pid + '&activityId=event_withdrawalPeriodElapsed&timers=true',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
6 changes: 4 additions & 2 deletions bruno/02-escalation/03-get-signature-deadline-timer.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ get {
}

script:pre-request {
// Wait for the async job executor to drive the process to the contract-signature wait state.
await new Promise((resolve) => setTimeout(resolve, 2500));
const pid = bru.getVar('processInstanceId');
await pollEngine(
'/job?processInstanceId=' + pid + '&activityId=event_signatureDeadline&timers=true',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
4 changes: 2 additions & 2 deletions bruno/02-escalation/05-assert-rejected.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ get {
}

script:pre-request {
// Let the rejection's async continuation settle before reading the status.
await new Promise((resolve) => setTimeout(resolve, 1500));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.status === 'REJECTED');
}

assert {
Expand Down
4 changes: 2 additions & 2 deletions bruno/03-abort/03-sign-contract.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ post {
}

script:pre-request {
// Wait for the async job executor to drive the process to the contract-signature wait state.
await new Promise((resolve) => setTimeout(resolve, 2500));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null);
}

assert {
Expand Down
6 changes: 3 additions & 3 deletions bruno/03-abort/04-withdraw.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ post {
}

script:pre-request {
// Wait for the bike order (and insurance/contract) to complete, so they get compensated by the
// saga rollback when the application is withdrawn.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id,
(b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null);
}

assert {
Expand Down
7 changes: 4 additions & 3 deletions bruno/03-abort/05-get-clarify-return-task.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ get {
}

script:pre-request {
// Compensation (event sub-process -> call activity -> requestCancellation) runs asynchronously
// before the cancelBikeOrder sub-process parks on the clarify-return task.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollEngine(
'/task?processInstanceBusinessKey=' + id + '&taskDefinitionKey=userTask_clarifyReturn',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
6 changes: 4 additions & 2 deletions bruno/03-abort/07-assert-cancelled.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ get {
}

script:pre-request {
// Completing the task drives the remaining compensation + confirmation asynchronously to the end.
await new Promise((resolve) => setTimeout(resolve, 3000));
const pid = bru.getVar('processInstanceId');
await pollEngine(
'/history/activity-instance?processInstanceId=' + pid + '&activityId=endEvent_applicationCancelled',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
4 changes: 2 additions & 2 deletions bruno/04-not-solvent/02-assert-rejected.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ get {
}

script:pre-request {
// The whole validate -> DMN -> reject chain runs through the async job executor.
await new Promise((resolve) => setTimeout(resolve, 2500));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.status === 'REJECTED');
}

assert {
Expand Down
4 changes: 2 additions & 2 deletions bruno/05-bike-unavailable/03-sign-contract.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ post {
}

script:pre-request {
// Wait for the async job executor to drive the process to the contract-signature wait state.
await new Promise((resolve) => setTimeout(resolve, 2500));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null);
}

assert {
Expand Down
5 changes: 3 additions & 2 deletions bruno/05-bike-unavailable/04-clarify-alternative.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ post {
}

script:pre-request {
// Wait for the order to find the requested bike unavailable and park on the clarify-alternative user task.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/tasks/clarify-alternative',
(b, s) => s === 200 && Array.isArray(b) && b.some((t) => t.applicationId === id));
}

body:json {
Expand Down
5 changes: 3 additions & 2 deletions bruno/05-bike-unavailable/05-report-handover.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ post {
}

script:pre-request {
// Wait for the re-order of the alternative bike to succeed and the parallel branches to join.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id,
(b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null);
}

assert {
Expand Down
6 changes: 4 additions & 2 deletions bruno/05-bike-unavailable/06-get-withdrawal-timer.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ get {
}

script:pre-request {
// Wait for the handover message's async continuation to reach the withdrawal-period timer.
await new Promise((resolve) => setTimeout(resolve, 2500));
const pid = bru.getVar('processInstanceId');
await pollEngine(
'/job?processInstanceId=' + pid + '&activityId=event_withdrawalPeriodElapsed&timers=true',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
6 changes: 4 additions & 2 deletions bruno/05-bike-unavailable/08-assert-leasing-active.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ get {
}

script:pre-request {
// Wait for the withdrawal-period timer's continuation to reach the leasing-active end event.
await new Promise((resolve) => setTimeout(resolve, 1500));
const pid = bru.getVar('processInstanceId');
await pollEngine(
'/history/activity-instance?processInstanceId=' + pid + '&activityId=endEvent_leasingActive',
(b) => Array.isArray(b) && b.length >= 1);
}

assert {
Expand Down
4 changes: 2 additions & 2 deletions bruno/06-list-and-inbox/04-sign-contract.bru
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ post {
}

script:pre-request {
// Give the engine a moment to reach the "wait for signature" state after submission.
await new Promise((resolve) => setTimeout(resolve, 2000));
const id = bru.getVar('applicationId');
await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null);
}

assert {
Expand Down
5 changes: 3 additions & 2 deletions bruno/06-list-and-inbox/05-inbox-has-task.bru
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ get {
}

script:pre-request {
// Wait for the order to run, find BIKE-OOS unavailable, and park on the clarify-alternative task.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/tasks/clarify-alternative',
(b, s) => s === 200 && Array.isArray(b) && b.some((t) => t.applicationId === id));
}

assert {
Expand Down
5 changes: 3 additions & 2 deletions bruno/06-list-and-inbox/07-inbox-empty.bru
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ get {
}

script:pre-request {
// Wait for the clarification to be completed and the task to leave the inbox.
await new Promise((resolve) => setTimeout(resolve, 3000));
const id = bru.getVar('applicationId');
await pollApp('/api/tasks/clarify-alternative',
(b, s) => s === 200 && Array.isArray(b) && !b.some((t) => t.applicationId === id));
}

assert {
Expand Down
47 changes: 47 additions & 0 deletions bruno/collection.bru
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
script:pre-request {
// Shared eventual-consistency polling helpers (see ADR-0012). Budgets are
// env-driven: pollTimeoutMs / pollIntervalMs.
const axios = require('axios');

const timeoutDefault = Number(bru.getEnvVar('pollTimeoutMs') || 20000);
const intervalDefault = Number(bru.getEnvVar('pollIntervalMs') || 500);

globalThis.pollUntil = async function (config, predicate, opts) {
opts = opts || {};
const timeoutMs = Number(opts.timeoutMs != null ? opts.timeoutMs : timeoutDefault);
const intervalMs = Number(opts.intervalMs != null ? opts.intervalMs : intervalDefault);
const deadline = Date.now() + timeoutMs;
let attempts = 0;
let last;
for (;;) {
attempts++;
try {
last = await axios(Object.assign({ validateStatus: () => true }, config));
if (predicate(last.data, last.status)) return last;
} catch (err) {
last = { status: 0, data: null, error: err.message };
}
if (Date.now() >= deadline) {
const where = (config.method || 'get').toUpperCase() + ' ' + config.url;
console.log('pollUntil: condition not met after ' + attempts + ' attempt(s) / '
+ timeoutMs + 'ms (' + where + ', last status ' + (last && last.status) + ')');
return last;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
};

// GET {{baseUrl}}{path} (the application read model) until predicate(data, status) holds.
globalThis.pollApp = function (path, predicate, opts) {
return globalThis.pollUntil(
{ method: 'get', url: bru.getEnvVar('baseUrl') + path },
predicate, opts);
};

// GET {{engineRest}}{path} (a CIB seven / Camunda 7 engine query) until predicate holds.
globalThis.pollEngine = function (path, predicate, opts) {
return globalThis.pollUntil(
{ method: 'get', url: bru.getEnvVar('engineRest') + path },
predicate, opts);
};
}
2 changes: 2 additions & 0 deletions bruno/environments/local.bru
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
vars {
baseUrl: http://localhost:8080
engineRest: http://localhost:8080/engine-rest
pollTimeoutMs: 20000
pollIntervalMs: 500
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ copied from [`adr/0000-adr-template.md`](adr/0000-adr-template.md). Write a new
| [0012](adr/0012-actuator-probes-and-prometheus-metrics.md) | Actuator health/liveness/readiness probes and Prometheus metrics, exposed out of the box. |
| [0013](adr/0013-flyway-for-database-migrations.md) | Flyway for versioned schema migrations; Hibernate switches to `validate`. |
| [0014](adr/0014-build-and-deployment-approach.md) | Build & deployment: `bootBuildImage` OCI image + nginx frontend + a one-command full-stack compose. |
| [0015](adr/0015-polling-for-eventual-consistency-in-e2e-tests.md) | Poll for eventual consistency in the Bruno e2e suite instead of fixed sleeps; env-tuned budgets, CLI pinned. |

## Diagrams

Expand Down
80 changes: 80 additions & 0 deletions docs/adr/0015-polling-for-eventual-consistency-in-e2e-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 0015 — Poll for eventual consistency in end-to-end tests

- **Status:** Accepted
- **Date:** 2026-08-21

## Context

Every command endpoint in this API is **asynchronous**. A command controller hands a message or a
task completion to the embedded CIB seven (Camunda 7) engine and returns **`202 Accepted`**
immediately — it does not wait for the token to reach the next wait state. The observable effect
lands some unbounded time later: the async job executor drives the process to its next wait state,
a delegate runs and writes the read model (`leasing_application`), and only then does a subsequent
read (`GET /api/bike-leasing/{id}`, or an engine query on `/engine-rest`) see it. The gap between
"the command returned" and "its effect is visible" is real, and it is **environment-dependent** — a
loaded CI runner is slower than a laptop.

The Bruno end-to-end suite (`bruno/`) originally bridged that gap with **fixed sleeps** — 18
hand-tuned `setTimeout`s from 1.5 s to 3 s. That is the classic flaky-test anti-pattern: a sleep
tuned to pass locally loses the race under CI load (the `07-inbox-empty` scenario did exactly this),
while the safe-side sleeps waste minutes on every run. The same async shape is inherent to **every
process blueprint** in this family, so the fix has to be a reusable pattern, not a per-test number.

## Decision

We assert eventual state by **polling until the real condition holds, capped by a generous timeout** —
never by sleeping a guessed duration.

- **Shared helpers** live in `bruno/collection.bru` (a collection-level `script:pre-request`, so they
are in scope for every request): `pollUntil(config, predicate, opts)` and the convenience wrappers
`pollApp(path, predicate)` (GET the app read model) and `pollEngine(path, predicate)` (GET a CIB
seven engine query on `/engine-rest`). They return the instant the predicate is met and only wait
the full budget when something is genuinely wrong — at which point the request's own assertions
report the real, still-wrong state instead of a bare timeout.
- **Budgets are env-driven** (`pollTimeoutMs` / `pollIntervalMs` in the environment file), so a
sibling blueprint with different propagation characteristics tunes them **once**, in one place. A
scenario that needs a wider window (e.g. an incident-retry window) passes `{ timeoutMs }` at the
call site.
- **Each scenario polls for its own precondition or assertion**, mirroring the read model's
observable fields — the `status` enum (`RECEIVED → ORDERED → HANDED_OVER → ACTIVE`, plus
`WITHDRAWN`, `REJECTED`, `CANCELLED`) and delegate-set fields (`contractId`, `orderId`). Command
steps gate on the precondition that makes the command valid (e.g. `contractId != null` before
`sign-contract`; `status == "ORDERED" && orderId != null` before `report-handover`; the inbox
listing the item before completing the `clarify-alternative` user task).
- **Division of labour holds:** Bruno asserts the **synchronous request/response contract** (status
codes, DTO shape); genuinely engine-level, deterministic checks (timer fast-forward, full token
flow) stay in the JVM `@CamundaSpringProcessTest` layer with JGiven. See ADR-0004 for the test
layering.
- **The Bruno CLI is pinned** (`@usebruno/cli@4.0.0`): the script sandbox's capabilities (available
globals, the `require` whitelist the helpers depend on) can change between majors, so an unpinned
`latest` is a correctness risk, not just a supply-chain one.

## Consequences

- **Positive:** the suite is robust under CI load and *faster* in the common case — it waits exactly
as long as the engine needs. One env-tunable budget replaces 18 magic numbers, and the pattern
ports to every sibling blueprint.
- **Negative / trade-offs:** each polled step issues an extra read before the "official" request; a
predicate must be kept honest (it should mirror what the request asserts, or it silently waits out
the whole budget). Two read-model writes — `report-handover` and `withdraw` — set their status
(`HANDED_OVER` / `WITHDRAWN`) *before* the process advances, so tests that need proof the process
actually advanced assert the **downstream** delegate-driven state (`ACTIVE`, `CANCELLED`), not
those intermediate flips.
- **Neutral:** the "submit → poll an observable status" shape becomes the documented client contract
for these async APIs, for real consumers as much as for tests.

## Implementation notes

- The Bruno script sandbox exposes **no global `fetch`**; `require('axios')` works and is what the
helpers use. Helpers are attached to `globalThis` in the collection script so request-level scripts
can call them.
- Message commands correlate by `businessKey`/`applicationId` once the matching subscription is open,
so gating on the read-model precondition (rather than exact timing) is sufficient.
`clarify-alternative` is a user-task-backed completion, so its precondition gates on the inbox
(`/api/tasks/clarify-alternative`) listing the application.
- Engine-query steps (finding the process instance, a timer job, or a user task) `pollEngine` the
`/engine-rest` query until the resource is searchable, then the request re-issues it and captures
the id into a variable.
- **Deferred, not adopted:** a test-only endpoint exposing engine progress deterministically would be
the strongest guarantee, but it leaks engine internals into the API surface for test convenience.
Revisit only if predicate-based polling proves insufficient.
Loading