diff --git a/.claude/docs/client-java.md b/.claude/docs/client-java.md index 466a5fd04db..8571c3b0be3 100644 --- a/.claude/docs/client-java.md +++ b/.claude/docs/client-java.md @@ -11,5 +11,7 @@ Client `.java` under `/registry/public//...` is synchronized by `JavaSy - **Several writes that only make sense together are ONE transaction.** Every store call is otherwise its own transaction, so a multi-write operation that fails halfway leaves the earlier writes behind - an intent create-from committed the invoice header and the source's INVOICED flip and then failed on a line (#7069). `org.eclipse.dirigible.components.data.store.java.repository.UnitOfWork.call(() -> { ... })` runs the block on one session and one transaction (thread-bound, so every repository joins it; nested blocks defer to the outermost), reads see the block's own writes, and the outbox events dispatch only once the whole unit committed - which is why an announcement about the unit's outcome belongs INSIDE the block, recorded through the write it is about (the create-from's `-transitioned` rides its source's status flip): the unit's commit is what makes it true, and the outbox only hands out what committed, so a crash after the commit no longer loses the event (#7160). The `History` trail and document numbering deliberately stay outside - which is why anything that can refuse a create (a required input, a from-status guard, a create-from's source-row rule) is decided BEFORE the target's header is saved: a refusal after the save takes the row back but not the number it spent or the trail row it wrote (#7224). Alongside it, a generated repository now refuses a write that leaves a defaultless NOT NULL column empty with a `ValidationException` naming the property (a 400), instead of letting the statement come back as a driver-specific constraint violation; a column carrying a DEFAULT is exempt, the database supplying its value. +- **`custom/test/**` is a project's unit tests, never part of its client codebase** (#7643). `JavaSynchronizer.isAccepted` skips it (one rule, `ClientTestSources.isTestSource`, shared with the slice), because those tests import JUnit and the slice, which the platform does not ship. They run in-process with `mvn test` through `tests/tests-sdk` (`dirigible-sdk-test`, `@IntentSlice`): the module compiles the project folder as a TEST source root, so `gen/`, `custom/` and `custom/test/` share one `Class` per type, and the slice loads those classes from the test classpath instead of compiling them - a slice compiling into its own `ClientClassLoader` would hand the test a different `InvoiceLineEntity` than the one it imports. The slice is a hand-filled context behind `BeanProvider` (entity store, numbering, tenant, client bean container) on a private H2, reset before each test; the platform services with package-private constructors are built by same-package helpers in its jar (`IntentSlice*` under `data.store.java.manager`/`outbox`, `engine.numbering`, `api.messaging`), so a signature change breaks its compile rather than a downstream run. `sample-intent-money` (its `gen/` checked in) is its reference and test suite; `IntentSliceSampleIT` publishes it. See `tests/tests-sdk/README.md` for what it does not cover. + **Detailed guide:** [`components/engine/engine-java/CLAUDE.md`](components/engine/engine-java/CLAUDE.md). Read it before changing anything under `engine-java`, `data-store-java`, the `sdk.*` annotations, or the `*-java` templates — it covers the container, the consumers, the two handler styles + no-mixing rule, the `JavaHandler`-as-bean path, controller routing / OpenAPI / `@Roles`, `data-store-java` dynamic-map persistence, error surfacing, the **removed** internals (`RepositoryRegistry` / `RepositoryClassConsumer` / `DependencyResolver` / reflective fallback / `@Extension`), and the three-repo (platform + `dirigiblelabs/sample-java-*` + docs) sequencing. diff --git a/components/engine/engine-java/README.md b/components/engine/engine-java/README.md index f82735efd37..ee7b57f529b 100644 --- a/components/engine/engine-java/README.md +++ b/components/engine/engine-java/README.md @@ -241,6 +241,10 @@ done | sort > "$PAYLOAD/.compiled" Only write the marker when the `find` produced at least one class; a payload-only project skips it. +The `test/` exclusions keep the project's unit tests - `custom/test/**`, which the platform never +compiles - out of the module. Run them before packaging, with `mvn test` in the project +([`dirigible-sdk-test`](../../../tests/tests-sdk/README.md) runs them in-process). + ### 3. Compile and assemble ```bash diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientTestSources.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientTestSources.java new file mode 100644 index 00000000000..04ce3fd5f80 --- /dev/null +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientTestSources.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.runtime; + +import java.nio.file.Path; + +/** + * Where a project keeps the unit tests of its hand-written Java: {@code custom/test/**}, beside the + * {@code custom/} classes they test (dirigible #7643). Those sources are compiled and run by the + * module's own Maven build against JUnit, which the platform does not ship - so the client codebase + * the platform compiles must never contain them, and the in-process test slice must not mistake + * them for application classes either. Both ask this one rule. + */ +public final class ClientTestSources { + + private static final String CUSTOM_FOLDER = "custom"; + private static final String TEST_FOLDER = "test"; + + private ClientTestSources() {} + + /** + * Tells whether a source file lies under a {@code custom/test} folder. + * + * @param file the source file - absolute, or relative to any folder above {@code custom} + * @return {@code true} for a unit-test source of the project's hand-written Java + */ + public static boolean isTestSource(Path file) { + String previous = null; + for (Path element : file) { + String name = element.toString(); + if (CUSTOM_FOLDER.equals(previous) && TEST_FOLDER.equals(name)) { + return true; + } + previous = name; + } + return false; + } +} diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizer.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizer.java index 5adade7f7e0..b6a2df4413c 100644 --- a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizer.java +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizer.java @@ -10,6 +10,8 @@ package org.eclipse.dirigible.engine.java.synchronizer; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.text.ParseException; import java.util.ArrayList; import java.util.HashMap; @@ -26,6 +28,7 @@ import org.eclipse.dirigible.components.base.synchronizer.SynchronizerCallback; import org.eclipse.dirigible.components.api.platform.ProblemsFacade; import org.eclipse.dirigible.engine.java.domain.JavaFile; +import org.eclipse.dirigible.engine.java.runtime.ClientTestSources; import org.eclipse.dirigible.engine.java.runtime.CompileDiagnostic; import org.eclipse.dirigible.engine.java.runtime.JavaLoader; import org.eclipse.dirigible.engine.java.runtime.JavaSourceParser; @@ -117,6 +120,17 @@ public boolean isAccepted(String type) { return JavaFile.ARTEFACT_TYPE.equals(type); } + /** + * Every {@code .java} file except a project's unit tests ({@code custom/test/**}): those are + * compiled by the module's own Maven build against JUnit, which the platform does not ship, so + * compiling them here could only fail, and report a compilation problem for a file that is not + * broken. + */ + @Override + public boolean isAccepted(Path file, BasicFileAttributes attrs) { + return super.isAccepted(file, attrs) && !ClientTestSources.isTestSource(file); + } + @Override public String getFileExtension() { return FILE_EXTENSION; diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizerTestSourcesTest.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizerTestSourcesTest.java new file mode 100644 index 00000000000..f808cbe5cee --- /dev/null +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/synchronizer/JavaSynchronizerTestSourcesTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.synchronizer; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.nio.file.Path; + +import org.eclipse.dirigible.engine.java.runtime.JavaLoader; +import org.eclipse.dirigible.engine.java.service.JavaFileService; +import org.junit.jupiter.api.Test; + +/** + * A project's unit tests under {@code custom/test/**} are compiled by its own Maven build against + * JUnit (dirigible #7643); the platform must leave them out of the client codebase it compiles. + */ +class JavaSynchronizerTestSourcesTest { + + private static final Path REGISTRY = Path.of("/dirigible", "registry", "public"); + + private final JavaSynchronizer synchronizer = new JavaSynchronizer(mock(JavaFileService.class), mock(JavaLoader.class)); + + @Test + void acceptsTheApplicationSources() { + assertTrue(accepted("money", "custom", "LineVatAction.java")); + assertTrue(accepted("money", "gen", "money", "data", "invoiceline", "InvoiceLineRepository.java")); + } + + @Test + void skipsTheUnitTestsOfTheHandWrittenJava() { + assertFalse(accepted("money", "custom", "test", "LineVatActionTest.java")); + assertFalse(accepted("money", "custom", "test", "pricing", "DiscountTest.java")); + } + + @Test + void keepsAFolderNamedTestElsewhere() { + assertTrue(accepted("money", "gen", "test", "Fixture.java")); + assertTrue(accepted("money", "test", "Probe.java")); + assertTrue(accepted("money", "custom", "testing", "Helper.java")); + } + + private boolean accepted(String... segments) { + Path file = REGISTRY; + for (String segment : segments) { + file = file.resolve(segment); + } + return synchronizer.isAccepted(file, null); + } +} diff --git a/tests/pom.xml b/tests/pom.xml index 094cdb0016e..3f667c66c47 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -18,6 +18,7 @@ tests-framework + tests-sdk tests-integrations diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSliceSampleIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSliceSampleIT.java new file mode 100644 index 00000000000..8fe0c3e3107 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentSliceSampleIT.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.comparesEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; + +import org.eclipse.dirigible.engine.java.domain.JavaFile; +import org.eclipse.dirigible.engine.java.service.JavaFileService; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.base.ProjectDeployer; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import io.restassured.config.JsonConfig; +import io.restassured.config.RestAssuredConfig; +import io.restassured.http.ContentType; +import io.restassured.path.json.config.JsonPathConfig; + +/** + * The reference project of the in-process unit-test slice (dirigible #7643), published to the + * platform: its unit tests under {@code custom/test/} are compiled by the module's Maven build + * (where {@code dirigible-sdk-test} runs them), never by the platform - which has no JUnit to + * compile them against - while the application they test runs exactly as the slice runs it. + */ +class IntentSliceSampleIT extends IntegrationTest { + + private static final String PROJECT = "sample-intent-money"; + private static final String LINE_API = "/services/java/" + PROJECT + "/gen/money/api/invoiceline/InvoiceLineController"; + + @Autowired + private ProjectDeployer projectDeployer; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Autowired + private JavaFileService javaFileService; + + @Test + void publishesTheApplicationWithoutItsUnitTests() { + projectDeployer.deploy(PROJECT); + + List locations = javaFileService.findByProject(PROJECT) + .stream() + .map(JavaFile::getLocation) + .sorted() + .toList(); + assertEquals(List.of("/" + PROJECT + "/custom/LineVatAction.java", // + "/" + PROJECT + "/gen/money/api/invoiceline/InvoiceLineController.java", // + "/" + PROJECT + "/gen/money/data/invoiceline/InvoiceLineEntity.java", // + "/" + PROJECT + "/gen/money/data/invoiceline/InvoiceLineRepository.java"), locations, + "the platform compiles the application and leaves custom/test/ to the module's build"); + assertTrue(javaFileService.findByProject(PROJECT) + .stream() + .allMatch(file -> file.getError() == null), + "every application class compiled and wired: " + javaFileService.findByProject(PROJECT)); + + // The money path the slice's LineVatActionTest pins: 1 x 2.90 at 5% is 0.145, half-up 0.15. + RestAssuredConfig exactDecimals = RestAssuredConfig.config() + .jsonConfig(JsonConfig.jsonConfig() + .numberReturnType( + JsonPathConfig.NumberReturnType.BIG_DECIMAL)); + restAssuredExecutor.execute(() -> given().config(exactDecimals) + .contentType(ContentType.JSON) + .body("{\"Description\":\"Paper\",\"Quantity\":1,\"UnitPrice\":2.90,\"VatRate\":5}") + .when() + .post(LINE_API) + .then() + .statusCode(200) + .body("VatAmount", comparesEqualTo(new BigDecimal("0.15"))), + 25); + } +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/.gitignore b/tests/tests-integrations/src/main/resources/sample-intent-money/.gitignore new file mode 100644 index 00000000000..f7422424d01 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/.gitignore @@ -0,0 +1,3 @@ +# The generated Java is checked in on purpose: it is what the unit tests under custom/test compile and +# run against in the in-process slice (dirigible #7643). The repository-wide `**/gen` rule would hide it. +!/gen/ diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/README.md b/tests/tests-integrations/src/main/resources/sample-intent-money/README.md new file mode 100644 index 00000000000..64c2041888f --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/README.md @@ -0,0 +1,26 @@ +# sample-intent-money + +The reference project of the in-process unit-test slice +([eclipse-dirigible/dirigible#7643](https://github.com/eclipse-dirigible/dirigible/issues/7643)): one +entity whose money field is computed by hand-written Java, and the unit tests of that Java. + +- **`app.intent`** - an `InvoiceLine` whose `vatAmount` is calculated on every create and update by + `calculatedActionOnCreate`/`OnUpdate: LineVatAction`. +- **`custom/LineVatAction.java`** - quantity x unit price x rate, half-up to the cent, in + `BigDecimal` throughout. +- **`custom/test/LineVatActionTest.java`** - its unit tests, run in-process by + [`dirigible-sdk-test`](../../../../../tests-sdk/README.md) (`@IntentSlice`): `1 x 2.90 @ 5%` is + exactly 0.145, stored as 0.15; computed in `double` it is 0.14499999... and the test fails on the + 0.14 it stores. They run with `mvn test` in `tests/tests-sdk`, whose test suite this folder is. +- **`gen/`** - the Java the intent generates, checked in because it is what the tests compile + against (only the Java: the UI, schema and roles come back on the next Generate). + +`IntentSliceSampleIT` publishes the folder to a running platform and asserts that the application +compiles without its unit tests - the platform never compiles `custom/test/**` - and computes the +same VAT over REST. + +## Regenerating `gen/` + +After a change to `app.intent`, import the folder into a workspace, open `app.intent` and click +**Generate**, then copy the generated `gen/**/*.java` back here and run `mvn test` in +`tests/tests-sdk`. diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/app.intent b/tests/tests-integrations/src/main/resources/sample-intent-money/app.intent new file mode 100644 index 00000000000..2e246e60543 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/app.intent @@ -0,0 +1,17 @@ +name: money +description: The reference project of the in-process unit-test slice (dirigible #7643) - a money field calculated by a hand-written custom/ action, with its unit tests under custom/test + +entities: + # One invoice line. Its VAT is the money path under test: calculated on the server by the + # hand-written LineVatAction on every create and update, never accepted from the caller. + - name: InvoiceLine + imports: | + import custom.LineVatAction; + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: description, type: string, required: true, length: 200 } + - { name: quantity, type: decimal, required: true, precision: 18, scale: 3 } + - { name: unitPrice, type: decimal, required: true, precision: 18, scale: 2 } + - { name: vatRate, type: decimal, required: true, precision: 5, scale: 2 } + - { name: vatAmount, type: decimal, precision: 18, scale: 2, readOnly: true, + calculatedActionOnCreate: LineVatAction, calculatedActionOnUpdate: LineVatAction } diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/custom/LineVatAction.java b/tests/tests-integrations/src/main/resources/sample-intent-money/custom/LineVatAction.java new file mode 100644 index 00000000000..72a8f865b79 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/custom/LineVatAction.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package custom; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.db.CalculatedField; + +import gen.money.data.invoiceline.InvoiceLineEntity; + +/** + * The VAT of an invoice line: quantity x unit price x rate, rounded half-up to the cent. Computed in + * {@link BigDecimal} from start to finish - a {@code double} evaluation turns {@code 1 x 2.90 @ 5%} + * into {@code 0.14499999...} and rounds it to 0.14 instead of 0.15, which is exactly what + * {@code custom/test/LineVatActionTest} pins. + */ +@Component +public class LineVatAction implements CalculatedField { + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final int CENTS = 2; + + @Override + public BigDecimal calculate(InvoiceLineEntity line) { + // The repository calculates before it validates, so a line missing an input reaches here; the + // validation that follows refuses it with the missing field's name. + if (line.Quantity == null || line.UnitPrice == null || line.VatRate == null) { + return null; + } + return line.Quantity.multiply(line.UnitPrice) + .multiply(line.VatRate) + .divide(HUNDRED) + .setScale(CENTS, RoundingMode.HALF_UP); + } +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/custom/test/LineVatActionTest.java b/tests/tests-integrations/src/main/resources/sample-intent-money/custom/test/LineVatActionTest.java new file mode 100644 index 00000000000..76465baa926 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/custom/test/LineVatActionTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package custom.test; + +import static org.eclipse.dirigible.sdk.test.Slice.expectRefused; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; + +import org.eclipse.dirigible.sdk.test.IntentSlice; +import org.eclipse.dirigible.sdk.test.Slice; +import org.junit.jupiter.api.Test; + +import custom.LineVatAction; +import gen.money.data.invoiceline.InvoiceLineEntity; +import gen.money.data.invoiceline.InvoiceLineRepository; + +/** + * The VAT of an invoice line, as the application computes it on a write - through the generated + * repository, which calls {@link LineVatAction} - not as a test re-implements it. Runs in-process with + * {@code mvn test}: no platform, no server. + */ +@IntentSlice +class LineVatActionTest { + + private static final String LINE_CREATED = "sample-intent-money-InvoiceLine-InvoiceLine"; + + /** + * {@code 1 x 2.90 x 5%} is exactly 0.145, which rounds half-up to 0.15. In {@code double} the same + * product is 0.14499999999999999 and rounds to 0.14 - the cent a delegate computing money in + * {@code double} silently loses. + */ + @Test + void roundsTheVatHalfUpToTheCent(Slice slice) { + InvoiceLineEntity line = slice.given(InvoiceLineEntity.class) + .with("Description", "Paper") + .with("Quantity", 1) + .with("UnitPrice", "2.90") + .with("VatRate", 5) + .saved(); + + assertEquals(new BigDecimal("0.15"), line.VatAmount); + } + + @Test + void computesTheVatOnTheServerWhateverTheCallerSent(Slice slice) { + InvoiceLineEntity line = slice.given(InvoiceLineEntity.class) + .with("Description", "Toner") + .with("Quantity", 3) + .with("UnitPrice", "11.50") + .with("VatRate", 20) + .with("VatAmount", "999.99") + .saved(); + + assertEquals(new BigDecimal("6.90"), line.VatAmount); + } + + @Test + void recalculatesTheVatWhenTheLineChanges(Slice slice, InvoiceLineRepository lines) { + InvoiceLineEntity line = slice.given(InvoiceLineEntity.class) + .with("Description", "Paper") + .with("Quantity", 1) + .with("UnitPrice", "2.90") + .with("VatRate", 5) + .saved(); + + line.Quantity = new BigDecimal("3"); + lines.update(line); + + assertEquals(new BigDecimal("0.44"), lines.findById(line.Id).VatAmount); + } + + @Test + void refusesALineWithoutAQuantityAndStoresNothing(Slice slice, InvoiceLineRepository lines) { + var refusal = expectRefused(() -> slice.given(InvoiceLineEntity.class) + .with("Description", "Paper") + .with("UnitPrice", "2.90") + .with("VatRate", 5) + .saved()); + + assertEquals("InvoiceLine.Quantity is required", refusal.getMessage()); + assertEquals(0, lines.count()); + } + + @Test + void leavesTheVatEmptyWhileAnInputIsMissing(Slice slice) { + InvoiceLineEntity line = slice.given(InvoiceLineEntity.class) + .with("Quantity", 1) + .entity(); + + assertNull(slice.bean(LineVatAction.class) + .calculate(line)); + } + + @Test + void announcesTheCreatedLineWithItsVat(Slice slice) { + slice.given(InvoiceLineEntity.class) + .with("Description", "Paper") + .with("Quantity", 1) + .with("UnitPrice", "2.90") + .with("VatRate", 5) + .saved(); + + List announced = slice.sentTo(LINE_CREATED); + assertEquals(1, announced.size(), "one create event per saved line, got: " + slice.sent()); + assertTrue(announced.get(0) + .contains("0.15"), + "the event carries the stored VAT: " + announced.get(0)); + } +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/api/invoiceline/InvoiceLineController.java b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/api/invoiceline/InvoiceLineController.java new file mode 100644 index 00000000000..398bfbbc913 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/api/invoiceline/InvoiceLineController.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package gen.money.api.invoiceline; + +import gen.money.data.invoiceline.InvoiceLineEntity; +import gen.money.data.invoiceline.InvoiceLineRepository; + +import org.eclipse.dirigible.components.api.security.UserFacade; +import org.eclipse.dirigible.sdk.platform.Documentation; +import org.eclipse.dirigible.sdk.http.Body; +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.http.Controller; +import org.eclipse.dirigible.sdk.http.Delete; +import org.eclipse.dirigible.sdk.http.Get; +import org.eclipse.dirigible.sdk.http.PathParam; +import org.eclipse.dirigible.sdk.http.Post; +import org.eclipse.dirigible.sdk.http.Put; +import org.eclipse.dirigible.sdk.http.QueryParam; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Controller +@Component("money_InvoiceLineController") +@Documentation("sample-intent-money - InvoiceLine Controller") +public class InvoiceLineController { + + private static final Set FILTER_FIELDS = Set.of("Id", "Description", "Quantity", "UnitPrice", "VatRate", "VatAmount"); + + /** Reports a system-owned column a create carried a value for - see dropSystemOwnedOnCreate. */ + private static final Logger LOG = Logging.getLogger("gen.money.api.invoiceline.InvoiceLineController"); + + private final InvoiceLineRepository repository; + + public InvoiceLineController(InvoiceLineRepository repository) { + this.repository = repository; + } + + @Get + @Documentation("List InvoiceLine") + public List getAll(@QueryParam("$limit") Integer limit, + @QueryParam("$offset") Integer offset) { + checkPermissions("read"); + int actualLimit = limit != null ? limit.intValue() : 20; + int actualOffset = offset != null ? offset.intValue() : 0; + List result = repository.findAll(actualLimit, actualOffset); + return result; + } + + @Get("/count") + @Documentation("Count InvoiceLine") + public Map count() { + checkPermissions("read"); + return Map.of("count", repository.count()); + } + + @Post("/count") + @Documentation("Count InvoiceLine with filter") + public Map countWithFilter(@Body Map filter) { + checkPermissions("read"); + return Map.of("count", (long) runFilter(filter).size()); + } + + @Post("/search") + @Documentation("Search InvoiceLine") + public List search(@Body Map filter) { + checkPermissions("read"); + List result = runFilter(filter); + return result; + } + + @Get("/{id}") + @Documentation("Get InvoiceLine by id") + public InvoiceLineEntity getById(@PathParam("id") Integer id) { + checkPermissions("read"); + InvoiceLineEntity entity = repository.findOne(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "InvoiceLine not found")); + return entity; + } + + @Post + @Documentation("Create InvoiceLine") + public InvoiceLineEntity create(@Body InvoiceLineEntity entity) { + checkPermissions("write"); + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + InvoiceLineEntity checked = repository.calculatedForCreate(entity); + validate(checked); + // After every check above, so what a create refuses is unchanged; before the save, so the + // repository never sees a caller's value for a column it does not own (#7549, #7620). + dropSystemOwnedOnCreate(entity); + InvoiceLineEntity saved = repository.save(entity); + return repository.findOne(saved.Id).orElse(saved); + } + + @Put("/{id}") + @Documentation("Update InvoiceLine by id") + public InvoiceLineEntity update(@PathParam("id") Integer id, @Body InvoiceLineEntity entity) { + checkPermissions("write"); + entity.Id = id; + // The checks read the record as the write will store it - its calculated fields computed, not + // taken from the payload (#7544). What is persisted is still computed by the repository. + InvoiceLineEntity checked = repository.calculatedForUpdate(entity); + validate(checked); + return repository.update(entity); + } + + @Delete("/{id}") + @Documentation("Delete InvoiceLine by id") + public void deleteById(@PathParam("id") Integer id) { + checkPermissions("write"); + if (repository.findOne(id).isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "InvoiceLine not found"); + } + try { + repository.deleteById(id); + } catch (RuntimeException e) { + // A foreign-key violation means other records still reference this one - a data-integrity + // conflict the user can act on, not a server error. + if (isConstraintViolation(e)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "This InvoiceLine is referenced by other records and cannot be deleted"); + } + throw e; + } + } + + /** Whether a persistence failure is (caused by) a database constraint violation. */ + private static boolean isConstraintViolation(Throwable e) { + for (Throwable cause = e; cause != null; cause = cause.getCause() == cause ? null : cause.getCause()) { + String type = cause.getClass().getName(); + if (type.contains("ConstraintViolation") || type.contains("DataIntegrityViolation")) { + return true; + } + } + return false; + } + + private List runFilter(Map filter) { + StringBuilder hql = new StringBuilder("from money_InvoiceLineEntity e"); + Map params = new LinkedHashMap<>(); + boolean first = true; + if (filter != null && filter.get("equals") instanceof Map equals) { + for (Map.Entry entry : equals.entrySet()) { + String field = requireKnownField(String.valueOf(entry.getKey())); + String paramName = "p" + params.size(); + hql.append(first ? " where e." : " and e.").append(field).append(" = :").append(paramName); + params.put(paramName, coerceValue(field, entry.getValue())); + first = false; + } + } + if (filter != null && filter.get("conditions") instanceof List conditions) { + for (Object raw : conditions) { + if (!(raw instanceof Map condition)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid filter condition"); + } + String field = requireKnownField(String.valueOf(condition.get("propertyName"))); + String operator = String.valueOf(condition.get("operator")).toUpperCase(Locale.ROOT); + Object value = condition.get("value"); + String paramName = "p" + params.size(); + String clause = switch (operator) { + case "EQ" -> "e." + field + " = :" + paramName; + case "IN" -> { + if (!(value instanceof Collection)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "IN value must be a list for field: " + field); + } + yield "e." + field + " in (:" + paramName + ")"; + } + case "LIKE" -> "e." + field + " like :" + paramName; + default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported operator: " + operator); + }; + hql.append(first ? " where " : " and ").append(clause); + params.put(paramName, operator.equals("IN") ? value : coerceValue(field, value)); + first = false; + } + } + return repository.query(hql.toString(), params); + } + + private static String requireKnownField(String field) { + if (!FILTER_FIELDS.contains(field)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown filter field: " + field); + } + return field; + } + + // Coerce a JSON-sourced filter value to the entity property's Java type so Hibernate can bind it. + // Date/timestamp filters arrive as ISO strings and must become LocalDate/LocalDateTime/Instant, + // else binding fails with "argument [2026-07-01] is not assignable to java.time.LocalDate". Reflects + // the entity field type (the field is already validated against FILTER_FIELDS). LIKE/string values + // and already-typed numbers pass through unchanged. + private static Object coerceValue(String field, Object value) { + if (!(value instanceof String s) || s.isBlank()) { + return value; + } + try { + Class type = InvoiceLineEntity.class.getField(field) + .getType(); + if (type == java.time.LocalDate.class) { + return java.time.LocalDate.parse(s); + } + if (type == java.time.LocalDateTime.class) { + return java.time.LocalDateTime.parse(s); + } + if (type == java.time.Instant.class) { + return java.time.Instant.parse(s); + } + if (type == Integer.class) { + return Integer.valueOf(s); + } + if (type == Long.class) { + return Long.valueOf(s); + } + if (type == java.math.BigDecimal.class) { + return new java.math.BigDecimal(s); + } + if (type == Double.class) { + return Double.valueOf(s); + } + if (type == Boolean.class || type == boolean.class) { + return Boolean.valueOf(s); + } + } catch (ReflectiveOperationException | RuntimeException e) { + // Unknown field or unparseable value: leave as-is and let Hibernate's validator report it. + } + return value; + } + + private void checkPermissions(String op) { + if ("read".equals(op) && !(isInAnyRole("sample-intent-money.InvoiceLine.InvoiceLineReadOnly") || isInAnyRole("sample-intent-money.InvoiceLine.InvoiceLineFullAccess"))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + if ("write".equals(op) && !isInAnyRole("sample-intent-money.InvoiceLine.InvoiceLineFullAccess")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + } + + /** + * Drops the system-owned columns a create carried (#7549): the readOnly fields, the roll-up and + * aggregate targets, ProcessId(s), a label's Name - what update() preserves from the stored row. + * The platform or a system writer fills them on create, never the request. Reported like + * update()'s discard (#6937), so a client computing its own value for one of them is told rather + * than silently ignored; a date is dropped but not reported, its round-trip being lossy. + * + *

+ * This lives on the REST surface and NOT in the repository's save() on purpose: save() is the + * create path of every system writer - the attachment upload, the snapshot mint, a create-from, a + * posting - and those assign exactly these columns by design (#7620). + */ + private static void dropSystemOwnedOnCreate(InvoiceLineEntity entity) { + java.util.List discarded = new java.util.ArrayList<>(); + if (entity.VatAmount != null) { + discarded.add("VatAmount=" + entity.VatAmount); + } + entity.VatAmount = null; + if (!discarded.isEmpty()) { + LOG.warn("A create of InvoiceLine carried system-owned {} - discarded. These columns are filled by" + + " the platform or a system writer, never from the request.", discarded); + } + } + + // Public so a process task's write-back (the generated Write delegate) holds the + // reviewer's edits to exactly these rules and messages before it writes them (#7552). + public static void validate(InvoiceLineEntity entity) { + if (entity.Description == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The 'Description' property is required"); + } + if (entity.Description != null && entity.Description.length() > 200) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The 'Description' exceeds the maximum length of 200"); + } + if (entity.Quantity == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The 'Quantity' property is required"); + } + if (entity.UnitPrice == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The 'UnitPrice' property is required"); + } + if (entity.VatRate == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The 'VatRate' property is required"); + } + } + + /** + * Whether the caller holds ANY of a comma-separated role list. One role name is what the entity + * modeler's per-property read/write role and the convention-derived entity gates carry; a gate the + * intent's `permissions[].can:` tokens authorized, and the intent's `visibleTo: [A, B]` + * allow-list, arrive here as "A,B" and holding either of them is enough. + */ + private static boolean isInAnyRole(String roles) { + for (String role : roles.split(",")) { + String name = role.trim(); + if (!name.isEmpty() && UserFacade.isInRole(name)) { + return true; + } + } + return false; + } +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineEntity.java b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineEntity.java new file mode 100644 index 00000000000..e5ce1ee4045 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineEntity.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package gen.money.data.invoiceline; + +import org.eclipse.dirigible.sdk.db.Column; +import org.eclipse.dirigible.sdk.db.CreatedAt; +import org.eclipse.dirigible.sdk.db.CreatedBy; +import org.eclipse.dirigible.sdk.platform.Documentation; +import org.eclipse.dirigible.sdk.db.Entity; +import org.eclipse.dirigible.sdk.db.GeneratedValue; +import org.eclipse.dirigible.sdk.db.GenerationType; +import org.eclipse.dirigible.sdk.db.Id; +import org.eclipse.dirigible.sdk.db.Table; +import org.eclipse.dirigible.sdk.db.UpdatedAt; +import org.eclipse.dirigible.sdk.db.UpdatedBy; + +@Entity(name = "money_InvoiceLineEntity") +@Table(name = "MONEY_INVOICE_LINE") +@Documentation("InvoiceLine entity mapping") +public class InvoiceLineEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "INVOICE_LINE_ID", length = 20) + @Documentation("Id") + public Integer Id; + + @Column(name = "INVOICE_LINE_DESCRIPTION", length = 200, nullable = false) + @Documentation("Description") + public String Description; + + @Column(name = "INVOICE_LINE_QUANTITY", precision = 18, scale = 3, nullable = false) + @Documentation("Quantity") + public java.math.BigDecimal Quantity; + + @Column(name = "INVOICE_LINE_UNIT_PRICE", precision = 18, scale = 2, nullable = false) + @Documentation("UnitPrice") + public java.math.BigDecimal UnitPrice; + + @Column(name = "INVOICE_LINE_VAT_RATE", precision = 5, scale = 2, nullable = false) + @Documentation("VatRate") + public java.math.BigDecimal VatRate; + + @Column(name = "INVOICE_LINE_VAT_AMOUNT", precision = 18, scale = 2, nullable = true) + @Documentation("VatAmount") + public java.math.BigDecimal VatAmount; + +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineRepository.java b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineRepository.java new file mode 100644 index 00000000000..dc8e00010bb --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/gen/money/data/invoiceline/InvoiceLineRepository.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package gen.money.data.invoiceline; + +import org.eclipse.dirigible.components.data.store.java.repository.JavaRepository; +import org.eclipse.dirigible.sdk.component.Component; +import org.eclipse.dirigible.sdk.component.Repository; +import org.eclipse.dirigible.sdk.db.ValidationException; +import org.eclipse.dirigible.sdk.component.Beans; +import org.eclipse.dirigible.sdk.log.Logger; +import org.eclipse.dirigible.sdk.log.Logging; +// custom imports +import custom.LineVatAction; + + +@Repository +@Component("money_InvoiceLineRepository") +public class InvoiceLineRepository extends JavaRepository { + + /** + * Reports a system-owned column a full-row update() carried a different value for - see update() - + * and an aggregate guard skipped for a row that belongs to no key-tuple - see save(). + */ + private static final Logger LOG = Logging.getLogger("gen.money.data.invoiceline.InvoiceLineRepository"); + + public InvoiceLineRepository() { + super(InvoiceLineEntity.class); + } + + @Override + public InvoiceLineEntity save(InvoiceLineEntity entity) { + entity.VatAmount = Beans.get(LineVatAction.class).calculate(entity); + if (entity.Description == null) { + throw new ValidationException("InvoiceLine.Description is required"); + } + if (entity.Quantity == null) { + throw new ValidationException("InvoiceLine.Quantity is required"); + } + if (entity.UnitPrice == null) { + throw new ValidationException("InvoiceLine.UnitPrice is required"); + } + if (entity.VatRate == null) { + throw new ValidationException("InvoiceLine.VatRate is required"); + } + // The create event is handed to the write itself, which records it in the tenant's event outbox + // INSIDE the insert's transaction: row and event commit together, so a broker outage can + // neither swallow an event whose row exists nor fail a call whose row was written. Listeners + // (intent process triggers, the reactions under gen/events) see it the moment it is durable. + InvoiceLineEntity saved = super.save(entity, "sample-intent-money-InvoiceLine-InvoiceLine"); + return saved; + } + + @Override + public InvoiceLineEntity update(InvoiceLineEntity entity) { + // System-owned fields survive a partial payload: a caller's update carries only the fields + // its form edits, and a value it leaves null must not erase what the write path computed - + // the roll-up/aggregate targets (an invoice's Paid, a timesheet's total hours) and the + // platform-managed identifiers (document number, uuid). The #6226/#6306 lost-update family, + // user-update edition: read the stored row once and take the system-owned values from THERE, + // never from the payload. System writers stay on the targeted primitives (updateProperty / + // updateProperties / updateDerived), so this preservation cannot fight a workflow write. + InvoiceLineEntity existingRow = entity.Id == null ? null : findById(entity.Id); + if (existingRow != null) { + // ...but a caller that computed a DIFFERENT value for one of them is not sending a partial + // payload - it is a system writer on the wrong path, and its work is being thrown away. + // Preserving silently made that indistinguishable from success: a hand-written listener + // maintaining a pool via full-row update() kept returning 200 while the column stayed at + // its created value forever (#6937). So the discard is reported, once per write, naming + // what was dropped; a payload that carries the stored value back - every ordinary form + // round-trip - says nothing. + java.util.List discarded = new java.util.ArrayList<>(); + if (isDiscardedByPreservation(entity.VatAmount, existingRow.VatAmount)) { + discarded.add("VatAmount=" + entity.VatAmount); + } + entity.VatAmount = existingRow.VatAmount; + if (!discarded.isEmpty()) { + LOG.warn("A full-row update of InvoiceLine {} carried system-owned {} - discarded, the stored" + + " values were kept. update() never writes these columns; a writer that computes" + + " them must use the targeted updateProperty / updateProperties / updateDerived.", + entity.Id, discarded); + } + } + entity.VatAmount = Beans.get(LineVatAction.class).calculate(entity); + if (entity.Description == null) { + throw new ValidationException("InvoiceLine.Description is required"); + } + if (entity.Quantity == null) { + throw new ValidationException("InvoiceLine.Quantity is required"); + } + if (entity.UnitPrice == null) { + throw new ValidationException("InvoiceLine.UnitPrice is required"); + } + if (entity.VatRate == null) { + throw new ValidationException("InvoiceLine.VatRate is required"); + } + // The update event is recorded in the event outbox inside this write's own transaction - see + // save() - so the intent reactions under gen/events see exactly the changes that committed. + InvoiceLineEntity updated = super.update(entity, "sample-intent-money-InvoiceLine-InvoiceLine-updated"); + return updated; + } + + /** + * Persists changes WITHOUT publishing the "-updated" event. Intended for system-managed + * back-references — e.g. an intent process trigger writing ProcessId back onto the entity that + * started it. Going through {@link #update} would re-publish "InvoiceLine-updated" and spuriously + * re-fire onUpdate reactions (notifications, roll-ups, integrations) for a change the user never made. + */ + public InvoiceLineEntity updateWithoutEvent(InvoiceLineEntity entity) { + if (entity.Description == null) { + throw new ValidationException("InvoiceLine.Description is required"); + } + if (entity.Quantity == null) { + throw new ValidationException("InvoiceLine.Quantity is required"); + } + if (entity.UnitPrice == null) { + throw new ValidationException("InvoiceLine.UnitPrice is required"); + } + if (entity.VatRate == null) { + throw new ValidationException("InvoiceLine.VatRate is required"); + } + InvoiceLineEntity updated = super.update(entity); + return updated; + } + + /** + * Targeted write of DERIVED columns - roll-up totals and keyed aggregates maintained by the generated + * handlers under {@code gen/events} - that keeps the {@code "-updated"} event contract. + * + *

+ * Only the named columns are persisted, so a concurrent user write to any other column of the row + * cannot be reverted: a handler that read the row, recomputed one total and then merged the WHOLE row + * back silently reverted whatever the user changed in that window (the lost-update family of #6226 / + * #6306). The {@code "-updated"} event still fires so downstream reactions - notably a transitive + * roll-up whose own parent sits above this row - keep cascading exactly as they did when the handler + * persisted the full row, carrying the written column names under {@code DerivedWrite.MARKER} so an + * authored-change listener can skip a recompute that moved nothing it watches. Routed through + * {@link #updateProperties} so an entity that declares + * {@code checks:} still runs its gate, a labelled entity still refreshes its stored display Name, + * and a document line still resums the master it belongs to. + */ + public int updateDerived(Object id, java.util.Map values) { + if (values == null || values.isEmpty()) { + return 0; + } + // The event travels with the write: the resulting row is read back on the write's own + // connection, before its commit, and recorded in the event outbox alongside it. It is MARKED + // with the columns written, so a listener that reacts to authored changes only - a settlement + // re-allocating a corrected payment - can tell this recompute from an edit (#7557). + return org.eclipse.dirigible.components.data.store.java.repository.DerivedWrite.run(values.keySet(), + () -> updateProperties(id, values, "sample-intent-money-InvoiceLine-InvoiceLine-updated")); + } + + /** + * Whether the preservation of a system-owned column is DISCARDING work: the payload carried a + * value of its own and it is not the stored one. + * + *

+ * An absent value is the ordinary case the preservation exists for - a form PUTs only the fields it + * edits - and is never reported. A numeric value is compared by amount, not by representation: + * {@code 120.00} and {@code 120} are the same total, and a JSON round-trip through the browser's + * number type routinely changes the scale. Everything else is compared by value, arrays included + * (a BLOB column's identity would differ on every round-trip). + * + * @param payloadValue the value the caller sent, may be null + * @param storedValue the value the stored row holds, may be null + * @return true when the payload value is present and differs from the stored one + */ + private static boolean isDiscardedByPreservation(Object payloadValue, Object storedValue) { + if (payloadValue == null || java.util.Objects.deepEquals(payloadValue, storedValue)) { + return false; + } + if (payloadValue instanceof Number payloadNumber && storedValue instanceof Number storedNumber) { + return new java.math.BigDecimal(String.valueOf(payloadNumber)) + .compareTo(new java.math.BigDecimal(String.valueOf(storedNumber))) != 0; + } + return true; + } + + /** + * The record this create would store, as far as its calculated fields go - the row the controller's + * checks read before the write (#7544). The calculated expressions are assigned inside save(), so a + * check reading the submitted record saw whatever the caller sent for a calculated field (usually + * nothing, or the value of a previous save) and a soft or hard check on one never fired for a REST + * write. The defaults the create applies come first, as in save(), since an expression may read + * them. Only the neutral expressions are evaluated: a calculated ACTION may mint or mutate state, + * and runs on the write path alone. The submitted record itself is not touched - what is persisted + * is still computed by save(). + * + * @param submitted the record about to be created + * @return a copy carrying the computed values, or the record itself when nothing is calculated + */ + public InvoiceLineEntity calculatedForCreate(InvoiceLineEntity submitted) { + return submitted; + } + + /** + * The record this update would store, as far as its calculated and system-owned fields go - the row + * the controller's checks read before the write (#7544). Built as update() builds it: the + * system-owned fields taken from the stored row, the document totals recomputed, then the + * update-time expressions - so an expression such as Balance = Total - Paid reads the stored Paid, + * never the payload's. The same holds for a check that reads a system-owned field directly (#7633): + * a forbidWhen over a readOnly counter a roll-up maintains judges the stored counter, so a payload + * that omits the column, or carries a harmless value for it, cannot walk past the refusal. Only the + * neutral expressions are evaluated (an action runs on the write path alone), and the submitted + * record itself is not touched. + * + * @param submitted the record about to be updated, carrying its id + * @return a copy carrying the stored and computed values, or the record itself when the entity has + * nothing calculated and nothing system-owned + */ + public InvoiceLineEntity calculatedForUpdate(InvoiceLineEntity submitted) { + InvoiceLineEntity entity = copyOf(submitted); + InvoiceLineEntity stored = entity.Id == null ? null : findById(entity.Id); + if (stored != null) { + entity.VatAmount = stored.VatAmount; + } + return entity; + } + + private static InvoiceLineEntity copyOf(InvoiceLineEntity source) { + InvoiceLineEntity copy = new InvoiceLineEntity(); + copy.Id = source.Id; + copy.Description = source.Description; + copy.Quantity = source.Quantity; + copy.UnitPrice = source.UnitPrice; + copy.VatRate = source.VatRate; + copy.VatAmount = source.VatAmount; + return copy; + } + + @Override + public void delete(InvoiceLineEntity entity) { + // The delete event is recorded in the event outbox inside this write's own transaction - see + // save() - so the intent reactions under gen/events cannot miss a row that is really gone. + super.delete(entity, "sample-intent-money-InvoiceLine-InvoiceLine-deleted"); + } + + @Override + public void deleteById(Object id) { + // The payload is the row as the deleting transaction read it, recorded in the event outbox + // beside the delete itself - see save(). + super.deleteById(id, "sample-intent-money-InvoiceLine-InvoiceLine-deleted"); + } + +} diff --git a/tests/tests-integrations/src/main/resources/sample-intent-money/project.json b/tests/tests-integrations/src/main/resources/sample-intent-money/project.json new file mode 100644 index 00000000000..a03e83bbb94 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/sample-intent-money/project.json @@ -0,0 +1,4 @@ +{ + "guid": "sample-intent-money", + "actions": [] +} diff --git a/tests/tests-sdk/README.md b/tests/tests-sdk/README.md new file mode 100644 index 00000000000..f5b1e5dc58c --- /dev/null +++ b/tests/tests-sdk/README.md @@ -0,0 +1,123 @@ +# dirigible-sdk-test - unit tests for an intent application's hand-written Java + +The hand-written logic of a generated application lives in `custom/*.java`: calculated-field +actions, delegates, check predicates, allocation rules. `dirigible-sdk-test` runs that code **in the +test JVM** (dirigible [#7643](https://github.com/eclipse-dirigible/dirigible/issues/7643)): the +project's generated `gen/` and hand-written `custom/` classes on a private in-memory H2 database, +exactly as the platform runs them, with no web server, synchronizer, broker or IDE. A test takes +milliseconds; the application starts once per JVM (about a second). + +```java +@IntentSlice +class LineVatActionTest { + + @Test + void roundsTheVatHalfUpToTheCent(Slice slice) { + InvoiceLineEntity line = slice.given(InvoiceLineEntity.class) + .with("Description", "Paper") + .with("Quantity", 1) + .with("UnitPrice", "2.90") + .with("VatRate", 5) + .saved(); + + assertEquals(new BigDecimal("0.15"), line.VatAmount); + } +} +``` + +The reference project is +[`sample-intent-money`](../tests-integrations/src/main/resources/sample-intent-money): this module's +own test suite is that project's `custom/test/`. + +## What runs + +- **Entities through their generated repositories**: calculated fields and actions, validations, + declared checks, `UnitOfWork`, the event outbox - Hibernate on H2, tables created from the + entities as on the platform. +- **Components wired by the platform's own bean container** (`engine-java`'s `ComponentContainer`): + constructor, field and collection injection, `Beans.get(...)`, extension points. +- **Document numbers** from the project's `.numbers` series. +- **Sent messages are recorded, not delivered**: every entity event a write publishes (once it + committed, as on the platform) and every message the code sends, readable through + `slice.sent()` / `slice.sentTo(destination)`. Nothing listens, so a reaction or process the + event would start on the platform does not run. + +Before each test every table is emptied, identities and number series restart, and the recorded +messages are cleared. Slice tests share the application's static entry points, so they must not run +in parallel. + +## The API + +| Call | What it does | +| ---- | ------------ | +| `@IntentSlice` / `@IntentSlice(project = "...")` | Runs the class against the project in the working directory, or the folder named relative to it | +| `Slice` test-method parameter | The handle on the running application | +| an application component as a parameter | e.g. `InvoiceLineRepository lines`, a `custom/` action | +| `slice.given(Entity.class).with("Field", value).saved()` | Builds a record and saves it through its repository; values convert to the field's type (`"2.90"` to `BigDecimal`, `"2026-01-31"` to `LocalDate`) | +| `slice.given(...).entity()` | The record unsaved, to call an action directly | +| `slice.repository(Entity.class)`, `slice.bean(Type.class)` | A repository, any component | +| `Slice.expectRefused(() -> ...)` | Asserts a validation or check refusal and returns it, `getMessage()` being what a REST caller reads | +| `slice.sent()`, `slice.sentTo(topic)` | The messages sent in this test | + +## Setting up a project module + +The tests live in the project's `custom/test/` folder - beside the code they test, and skipped by +the platform, which never compiles `custom/test/**` on publish. The module's build compiles the +project folder as a test source root, so `gen/`, `custom/` and `custom/test/` build together and the +tests see the very classes the slice runs. A `pom.xml` at the project root: + +```xml + + + + + org.eclipse.dirigible + dirigible-parent + ${dirigible.version} + pom + import + + + + + + + org.eclipse.dirigible + dirigible-sdk-test + ${dirigible.version} + test + + + + + ${project.basedir} + + + org.apache.maven.plugins + maven-compiler-plugin + + + gen/**/*.java + custom/**/*.java + + + + + +``` + +`mvn test` then runs every `custom/test/**/*Test.java`. Commit `gen/` - it is what the tests compile +against - and regenerate it whenever the intent changes, as for a publish. + +## Not covered (yet) + +- **Reactions to events** - listeners, process triggers, notifications: the slice records the event + and stops there. A `MailAsserter` for notify outcomes needs that dispatch first. +- **A fake clock**: generated rules read the system clock (`LocalDate.now()`, `Instant.now()`) + directly, so a clock the slice could fix would first have to reach every generated time read. +- **REST concerns** - permissions, the controllers' own `validate()`, the personal and partner + surfaces: cover them with an integration test. +- **Side tables from the generated `.schema`** (`_HISTORY`, `_LANG`): not created. A write of an + entity declaring `history: true` stores its row but logs that it could not record the change + history; a read of a `multilingual: true` entity returns the base values with a warning that the + language table is not accessible. diff --git a/tests/tests-sdk/pom.xml b/tests/tests-sdk/pom.xml new file mode 100644 index 00000000000..f71d4afaf26 --- /dev/null +++ b/tests/tests-sdk/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + org.eclipse.dirigible + dirigible-tests + 15.0.0-SNAPSHOT + ../pom.xml + + + Tests - SDK + dirigible-sdk-test + jar + An in-process JUnit 5 slice for the unit tests of an intent application's hand-written Java: the + project's generated and custom classes against H2, with no web server, synchronizers or IDE + + + ../../licensing-header.txt + ../../ + + ${project.basedir}/../tests-integrations/src/main/resources/sample-intent-money + + + + + + org.eclipse.dirigible + dirigible-components-api-modules-java + ${project.version} + + + org.eclipse.dirigible + dirigible-components-data-store-java + ${project.version} + + + org.eclipse.dirigible + dirigible-components-data-store + ${project.version} + + + org.eclipse.dirigible + dirigible-components-data-sources + ${project.version} + + + org.eclipse.dirigible + dirigible-components-engine-java + ${project.version} + + + org.eclipse.dirigible + dirigible-components-engine-numbering + ${project.version} + + + org.eclipse.dirigible + dirigible-components-api-messaging + ${project.version} + + + com.h2database + h2 + + + + + org.junit.jupiter + junit-jupiter + compile + + + org.mockito + mockito-core + compile + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + + + add-sample-project + generate-test-sources + + add-test-source + + + + ${sample.project} + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + ${sample.project} + + + + + + diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/api/messaging/IntentSliceMessaging.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/api/messaging/IntentSliceMessaging.java new file mode 100644 index 00000000000..025b5864dc2 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/api/messaging/IntentSliceMessaging.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.api.messaging; + +import org.eclipse.dirigible.components.listeners.service.MessageConsumer; +import org.eclipse.dirigible.components.listeners.service.MessageProducer; + +/** + * Points the static {@link MessagingFacade} at the in-process test slice's broker stand-in + * (dirigible #7643); the facade's constructor is package-private because the platform builds it as + * a Spring bean. + */ +public final class IntentSliceMessaging { + + private IntentSliceMessaging() {} + + /** + * Routes every later {@link MessagingFacade} call to the given consumer and producer. + * + * @param consumer what receives from queues + * @param producer what sends to queues and topics + */ + public static void install(MessageConsumer consumer, MessageProducer producer) { + new MessagingFacade(consumer, producer); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/manager/IntentSliceEntities.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/manager/IntentSliceEntities.java new file mode 100644 index 00000000000..b95fcd4a202 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/manager/IntentSliceEntities.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.data.store.java.manager; + +import java.util.Map; + +/** + * The in-process test slice's access to the entity registration of {@link JavaEntityManager} + * (dirigible #7643). It lives in the manager's package so that a change of the package-private API + * it reaches breaks this module's compilation, not a downstream test run. + */ +public final class IntentSliceEntities { + + private IntentSliceEntities() {} + + /** + * Registers every entity of an application and builds the session factory once over all of them - + * the public {@link JavaEntityManager#register(String, Class)} rebuilds it per entity. + * + * @param manager the entity manager + * @param entities the entity classes by registration key ({@code ::}) + */ + public static void registerAll(JavaEntityManager manager, Map> entities) { + entities.forEach(manager::registerWithoutRebuild); + manager.getSessionFactory(); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/outbox/IntentSliceOutbox.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/outbox/IntentSliceOutbox.java new file mode 100644 index 00000000000..576631939bb --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/data/store/java/outbox/IntentSliceOutbox.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.data.store.java.outbox; + +import org.eclipse.dirigible.components.base.tenant.TenantContext; +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; + +/** + * Builds the event outbox for the in-process test slice (dirigible #7643), whose constructors are + * package-private because the platform builds it as a Spring bean. + */ +public final class IntentSliceOutbox { + + private IntentSliceOutbox() {} + + /** + * Creates an outbox recording into the given datasources' default database. + * + * @param dataSourcesManager the source of the default database + * @param tenantContext the tenant the outbox records for + * @return the outbox + */ + public static EventOutbox create(DataSourcesManager dataSourcesManager, TenantContext tenantContext) { + return new EventOutbox(new EventOutboxStore(dataSourcesManager, tenantContext)); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/engine/numbering/IntentSliceNumbering.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/engine/numbering/IntentSliceNumbering.java new file mode 100644 index 00000000000..0b87f6e5456 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/components/engine/numbering/IntentSliceNumbering.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.engine.numbering; + +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; + +/** + * Builds the document-number service for the in-process test slice (dirigible #7643), whose + * constructors are package-private because the platform builds it as a Spring bean. + */ +public final class IntentSliceNumbering { + + private IntentSliceNumbering() {} + + /** + * Creates a document-number service allocating in the given datasources' default database. + * + * @param dataSourcesManager the source of the default database + * @return the service + */ + public static DocumentNumberService create(DataSourcesManager dataSourcesManager) { + return new DocumentNumberService(new DocumentNumberStore(dataSourcesManager)); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Given.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Given.java new file mode 100644 index 00000000000..0a6ca65e334 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Given.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.eclipse.dirigible.components.data.store.java.repository.JavaRepository; + +/** + * A record of one entity being put together for a test: + * {@code slice.given(InvoiceLineEntity.class).with("Quantity", 1).with("UnitPrice", "2.90").saved()}. + * Properties are the generated entity's own field names. A value is converted to the field's type + * when it is a number or text naming one - {@code "2.90"} for a {@code BigDecimal}, + * {@code "2026-01-31"} for a {@code LocalDate} - so a money value can be written exactly, without a + * {@code double} on the way. + * + * @param the entity type + */ +public final class Given { + + private final JavaRepository repository; + private final E entity; + + Given(JavaRepository repository) { + this.repository = repository; + this.entity = instantiate(repository.getEntityClass()); + } + + /** + * Sets one property of the record. + * + * @param property the entity field, e.g. {@code "UnitPrice"} + * @param value the value, or a number or text convertible to the field's type + * @return this + * @throws IllegalArgumentException if the entity has no such field, or the value does not fit it + */ + public Given with(String property, Object value) { + Field field = field(property); + try { + field.set(entity, convert(value, field)); + } catch (IllegalAccessException ex) { + throw new IllegalArgumentException("Cannot set [" + property + "] of " + entity.getClass() + .getName(), + ex); + } + return this; + } + + /** + * Saves the record through its generated repository - its calculated fields, validations, checks + * and events applying exactly as on a REST create. + * + * @return the saved record, its generated key populated + */ + public E saved() { + return repository.save(entity); + } + + /** + * The record as put together so far, not saved - for a call to a hand-written action directly. + * + * @return the unsaved record + */ + public E entity() { + return entity; + } + + private Field field(String property) { + Class type = repository.getEntityClass(); + try { + Field field = type.getField(property); + if (!Modifier.isStatic(field.getModifiers())) { + return field; + } + } catch (NoSuchFieldException ex) { + // reported below, with the fields the entity does have + } + String properties = Arrays.stream(type.getFields()) + .filter(candidate -> !Modifier.isStatic(candidate.getModifiers())) + .map(Field::getName) + .collect(Collectors.joining(", ")); + throw new IllegalArgumentException(type.getSimpleName() + " has no property [" + property + "]; it has: " + properties); + } + + private static Object convert(Object value, Field field) { + Class type = field.getType(); + if (value == null || type.isInstance(value)) { + return value; + } + String text = value.toString(); + try { + if (value instanceof Number || value instanceof CharSequence) { + if (type == BigDecimal.class) { + return new BigDecimal(text); + } + if (type == Integer.class) { + return Integer.valueOf(text); + } + if (type == Long.class) { + return Long.valueOf(text); + } + if (type == Double.class) { + return Double.valueOf(text); + } + } + if (value instanceof CharSequence) { + if (type == LocalDate.class) { + return LocalDate.parse(text); + } + if (type == LocalDateTime.class) { + return LocalDateTime.parse(text); + } + if (type == Instant.class) { + return Instant.parse(text); + } + if (type == Boolean.class) { + return Boolean.valueOf(text); + } + } + } catch (RuntimeException ex) { + throw new IllegalArgumentException("[" + text + "] is not a " + type.getSimpleName() + " for [" + field.getName() + "]", ex); + } + throw new IllegalArgumentException("Cannot assign a " + value.getClass() + .getSimpleName() + + " to [" + field.getName() + "] of type " + type.getSimpleName()); + } + + private static E instantiate(Class type) { + try { + return type.getDeclaredConstructor() + .newInstance(); + } catch (InstantiationException | IllegalAccessException | NoSuchMethodException ex) { + throw new IllegalStateException( + "Cannot create a " + type.getName() + " - a generated entity has a public no-argument constructor", ex); + } catch (InvocationTargetException ex) { + throw new IllegalStateException("The constructor of " + type.getName() + " failed", ex.getCause()); + } + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSlice.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSlice.java new file mode 100644 index 00000000000..ff7a3697f41 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSlice.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Runs a test class against an intent application in-process - the unit-test level for the + * hand-written Java of {@code custom/} (dirigible #7643). The project's generated {@code gen/} and + * hand-written {@code custom/} classes run on a private in-memory H2 database exactly as the + * platform runs them: entities through their generated repositories (calculated fields, + * validations, checks, events), components wired by the platform's own bean container, document + * numbers from the project's {@code .numbers} series. There is no web server, no synchronizer, no + * broker and no IDE, so a test takes milliseconds. + * + *

+ * The classes come from the test classpath: the module's build compiles the project folder as a + * test source root, so {@code gen/}, {@code custom/} and the tests in {@code custom/test/} build + * together - the platform itself never compiles {@code custom/test/}. The application starts once + * per JVM and project; before each test every table is emptied, identities and number series + * restart, and the recorded messages are cleared. Tests of one JVM share the application's static + * entry points, so slice tests must not run in parallel. + * + *

+ * {@literal @}IntentSlice
+ * class LineVatActionTest {
+ *     {@literal @}Test
+ *     void roundsHalfUpToTheCent(Slice slice) {
+ *         InvoiceLineEntity line = slice.given(InvoiceLineEntity.class)
+ *                                       .with("Quantity", 1).with("UnitPrice", "2.90").with("VatRate", 5)
+ *                                       .saved();
+ *         assertEquals(new BigDecimal("0.15"), line.VatAmount);
+ *     }
+ * }
+ * 
+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ExtendWith(IntentSliceExtension.class) +public @interface IntentSlice { + + /** + * The project folder - the one holding {@code gen/} and {@code custom/} - relative to the working + * directory. The default suits a module whose root is the project. + * + * @return the project folder + */ + String project() default "."; +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSliceExtension.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSliceExtension.java new file mode 100644 index 00000000000..9fc1e7880e1 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/IntentSliceExtension.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.nio.file.Path; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContextException; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.platform.commons.support.AnnotationSupport; + +/** + * The JUnit side of {@link IntentSlice}: starts each project's application once per JVM (closed + * with the JVM's root context), makes it the live one for the test about to run, resets it before + * each test, and hands the test its {@link Slice} - or any of the application's components - as a + * parameter. + */ +public final class IntentSliceExtension implements BeforeEachCallback, ParameterResolver { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(IntentSliceExtension.class); + + @Override + public void beforeEach(ExtensionContext context) { + runtime(context).reset(); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + Class type = parameterContext.getParameter() + .getType(); + return type == Slice.class || runtime(extensionContext).container() + .get(type) + .isPresent(); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + Class type = parameterContext.getParameter() + .getType(); + Slice slice = runtime(extensionContext).slice(); + return type == Slice.class ? slice : slice.bean(type); + } + + private static SliceRuntime runtime(ExtensionContext context) { + Path project = Path.of("") + .toAbsolutePath() + .resolve(annotation(context).project()) + .normalize(); + SliceRuntime runtime = context.getRoot() + .getStore(NAMESPACE) + .computeIfAbsent(project, SliceRuntime::new, SliceRuntime.class); + runtime.activate(); + return runtime; + } + + private static IntentSlice annotation(ExtensionContext context) { + for (Class type = context.getRequiredTestClass(); type != null; type = type.getEnclosingClass()) { + var annotation = AnnotationSupport.findAnnotation(type, IntentSlice.class); + if (annotation.isPresent()) { + return annotation.get(); + } + } + throw new ExtensionContextException( + IntentSliceExtension.class.getSimpleName() + " needs @" + IntentSlice.class.getSimpleName() + " on the test class"); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/ProjectClasses.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/ProjectClasses.java new file mode 100644 index 00000000000..1efdeab0179 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/ProjectClasses.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.dirigible.engine.java.runtime.ClientTestSources; + +/** + * The application classes of a project: every top-level class under {@code gen/} and + * {@code custom/}, its unit tests under {@code custom/test/} excepted - the same set the platform + * compiles on publish. The classes themselves come from the test classpath, compiled by the + * module's build, so a test and the slice share one {@link Class} per type. + */ +final class ProjectClasses { + + private static final List SOURCE_FOLDERS = List.of("gen", "custom"); + private static final String JAVA_EXTENSION = ".java"; + private static final List NOT_CLASSES = List.of("package-info.java", "module-info.java"); + + private ProjectClasses() {} + + /** + * Loads the application classes of a project. + * + * @param projectRoot the project folder (the one holding {@code gen/} and {@code custom/}) + * @param loader the class loader the test classes were loaded by + * @return the classes, in source-path order + * @throws IllegalStateException if the project has no sources, or a source has no class on the test + * classpath + */ + static List> load(Path projectRoot, ClassLoader loader) { + List names = classNames(projectRoot); + if (names.isEmpty()) { + throw new IllegalStateException("No Java sources under gen/ or custom/ of [" + projectRoot + + "] - point @IntentSlice(project = ...) at the project folder, or run the test from it"); + } + List> classes = new ArrayList<>(names.size()); + List missing = new ArrayList<>(); + for (String name : names) { + try { + classes.add(Class.forName(name, false, loader)); + } catch (ClassNotFoundException ex) { + missing.add(name); + } + } + if (!missing.isEmpty()) { + throw new IllegalStateException("The project's classes are not on the test classpath: " + missing + + " - compile the project folder as a test source root, so gen/, custom/ and custom/test/ build together"); + } + return classes; + } + + private static List classNames(Path projectRoot) { + List names = new ArrayList<>(); + for (String folder : SOURCE_FOLDERS) { + Path sources = projectRoot.resolve(folder); + if (!Files.isDirectory(sources)) { + continue; + } + try (Stream files = Files.walk(sources)) { + files.filter(Files::isRegularFile) + .map(projectRoot::relativize) + .filter(ProjectClasses::isApplicationSource) + .sorted() + .map(ProjectClasses::className) + .forEach(names::add); + } catch (IOException ex) { + throw new UncheckedIOException("Failed to list the Java sources of [" + sources + "]", ex); + } + } + return names; + } + + private static boolean isApplicationSource(Path file) { + String fileName = file.getFileName() + .toString(); + return fileName.endsWith(JAVA_EXTENSION) && !NOT_CLASSES.contains(fileName) && !ClientTestSources.isTestSource(file); + } + + /** + * {@code gen/money/data/Line.java} declares {@code gen.money.data.Line} - the platform's layout. + */ + private static String className(Path file) { + List segments = new ArrayList<>(); + file.forEach(segment -> segments.add(segment.toString())); + String fileName = segments.removeLast(); + segments.add(fileName.substring(0, fileName.length() - JAVA_EXTENSION.length())); + return String.join(".", segments); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SentMessage.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SentMessage.java new file mode 100644 index 00000000000..dc956c56528 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SentMessage.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +/** + * A message the application sent while a test ran - an entity event its repository published once + * the write committed, or a message its own code sent. The slice has no broker: nothing receives + * it, it is only recorded. + * + * @param destination the topic or queue it was sent to, e.g. + * {@code sample-intent-money-InvoiceLine-InvoiceLine} + * @param payload the message body, for an entity event the row as JSON + */ +public record SentMessage(String destination, String payload) { +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SingleTenantContext.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SingleTenantContext.java new file mode 100644 index 00000000000..1e022431a4c --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SingleTenantContext.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.util.List; + +import org.eclipse.dirigible.components.base.callable.CallableResultAndException; +import org.eclipse.dirigible.components.base.tenant.Tenant; +import org.eclipse.dirigible.components.base.tenant.TenantContext; +import org.eclipse.dirigible.components.base.tenant.TenantResult; + +/** + * The slice runs as the platform's default tenant, outside any tenant-scoped call - what a write + * made on a single-tenant instance sees. + */ +final class SingleTenantContext implements TenantContext { + + /** The platform's default tenant, as a request with no tenant subdomain resolves it. */ + static final Tenant DEFAULT_TENANT = new DefaultTenant(); + + @Override + public boolean isNotInitialized() { + return true; + } + + @Override + public boolean isInitialized() { + return false; + } + + @Override + public Tenant getCurrentTenant() { + return DEFAULT_TENANT; + } + + @Override + public Result execute(Tenant tenant, CallableResultAndException callable) throws Exc { + return callable.call(); + } + + @Override + public Result execute(String tenantId, CallableResultAndException callable) throws Exc { + return callable.call(); + } + + @Override + public List> executeForEachTenant(CallableResultAndException callable) + throws Exc { + Result result = callable.call(); + return List.of(new TenantResult<>() { + + @Override + public Tenant getTenant() { + return DEFAULT_TENANT; + } + + @Override + public Result getResult() { + return result; + } + }); + } + + private static final class DefaultTenant implements Tenant { + + private static final long serialVersionUID = 1L; + + @Override + public String getId() { + return "default-tenant"; + } + + @Override + public boolean isDefault() { + return true; + } + + @Override + public String getName() { + return "Default"; + } + + @Override + public String getSubdomain() { + return "default"; + } + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Slice.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Slice.java new file mode 100644 index 00000000000..49e6513b1b0 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/Slice.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.eclipse.dirigible.components.data.store.java.repository.JavaRepository; +import org.eclipse.dirigible.sdk.db.ValidationException; +import org.junit.jupiter.api.function.Executable; + +/** + * A test's handle on the application running in an {@link IntentSlice}: its components, its + * entities' repositories, and what it sent. Declare it as a parameter of a test method. + */ +public final class Slice { + + private final SliceRuntime runtime; + + Slice(SliceRuntime runtime) { + this.runtime = runtime; + } + + /** + * A component of the application - a generated repository or controller, a hand-written action, + * delegate or service - exactly as the platform's bean container wires it. + * + * @param the component type + * @param type the component's class, or an interface it implements + * @return the component + * @throws IllegalArgumentException if the application has no such component + */ + public T bean(Class type) { + return runtime.container() + .get(type) + .orElseThrow(() -> new IllegalArgumentException("The application has no component of type " + type.getName())); + } + + /** + * The generated repository of an entity. + * + * @param the entity type + * @param entityClass the generated entity class + * @return its repository + * @throws IllegalArgumentException if the application has no repository for it + */ + @SuppressWarnings("unchecked") + public JavaRepository repository(Class entityClass) { + return runtime.container() + .getAll(JavaRepository.class) + .stream() + .filter(repository -> repository.getEntityClass() == entityClass) + .findFirst() + .map(repository -> (JavaRepository) repository) + .orElseThrow(() -> new IllegalArgumentException("The application has no repository of " + entityClass.getName())); + } + + /** + * Starts a record of an entity, to be saved through its repository. + * + * @param the entity type + * @param entityClass the generated entity class + * @return the record, every property unset + */ + public Given given(Class entityClass) { + return new Given<>(repository(entityClass)); + } + + /** + * Every message the application sent in this test so far - the events its repositories published + * and the messages its own code sent - in order. + * + * @return the messages + */ + public List sent() { + return runtime.sent(); + } + + /** + * The payloads sent to one topic or queue in this test so far, in order. + * + * @param destination the topic or queue + * @return the payloads + */ + public List sentTo(String destination) { + return runtime.sent() + .stream() + .filter(message -> message.destination() + .equals(destination)) + .map(SentMessage::payload) + .toList(); + } + + /** + * Asserts that a write is refused by a validation or a declared check, and returns the refusal - + * whose message is what the caller of the REST API would read. + * + * @param write the write expected to be refused + * @return the refusal + */ + public static ValidationException expectRefused(Executable write) { + return assertThrows(ValidationException.class, write, "Expected the write to be refused"); + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceDatabase.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceDatabase.java new file mode 100644 index 00000000000..de9d46256ff --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceDatabase.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.dirigible.components.database.DatabaseSystem; +import org.eclipse.dirigible.components.database.DirigibleConnection; +import org.eclipse.dirigible.components.database.DirigibleDataSource; +import org.h2.jdbcx.JdbcDataSource; + +/** + * The slice's own in-memory H2 database, presented as the platform's default + * {@link DirigibleDataSource}. Nothing else uses it, so wiping it between tests is safe. + */ +final class SliceDatabase implements AutoCloseable { + + private static final String DATA_SOURCE_NAME = "DefaultDB"; + private static final AtomicInteger INSTANCES = new AtomicInteger(); + + private final JdbcDataSource h2 = new JdbcDataSource(); + private final DirigibleDataSource dataSource; + + SliceDatabase() { + // DB_CLOSE_DELAY keeps the database alive between connections; close() shuts it down. + h2.setURL("jdbc:h2:mem:intent-slice-" + INSTANCES.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + dataSource = proxy(DirigibleDataSource.class, new DataSourceHandler()); + } + + /** @return the database as the platform's default datasource */ + DirigibleDataSource dataSource() { + return dataSource; + } + + /** + * Empties every table and restarts every identity, keeping the schema - each test starts from the + * state a freshly provisioned application has. + * + * @throws SQLException if a statement fails + */ + void clear() throws SQLException { + try (Connection connection = h2.getConnection(); Statement statement = connection.createStatement()) { + List tables = new ArrayList<>(); + try (ResultSet resultSet = statement.executeQuery( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC' AND TABLE_TYPE = 'BASE TABLE'")) { + while (resultSet.next()) { + tables.add(resultSet.getString(1)); + } + } + statement.execute("SET REFERENTIAL_INTEGRITY FALSE"); + try { + for (String table : tables) { + statement.execute("TRUNCATE TABLE \"" + table.replace("\"", "\"\"") + "\" RESTART IDENTITY"); + } + } finally { + statement.execute("SET REFERENTIAL_INTEGRITY TRUE"); + } + } + } + + @Override + public void close() throws SQLException { + try (Connection connection = h2.getConnection(); Statement statement = connection.createStatement()) { + statement.execute("SHUTDOWN"); + } + } + + private static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(SliceDatabase.class.getClassLoader(), new Class[] {type}, handler)); + } + + /** + * Invokes the same method on the plain JDBC object, rethrowing what it threw rather than a wrapper. + * The method is looked up on the target: the platform interfaces redeclare some JDBC methods + * covariantly ({@code DirigibleDataSource.getConnection()}), which the target does not implement. + */ + private static Object delegate(Object target, Method method, Object[] args) throws Throwable { + try { + return target.getClass() + .getMethod(method.getName(), method.getParameterTypes()) + .invoke(target, args); + } catch (InvocationTargetException ex) { + throw ex.getCause(); + } + } + + /** + * What the platform asks a datasource beyond JDBC: its name, its database system, its lifecycle. + */ + private final class DataSourceHandler implements InvocationHandler { + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return switch (method.getName()) { + case "getConnection" -> proxy(DirigibleConnection.class, new ConnectionHandler((Connection) delegate(h2, method, args))); + case "getName" -> DATA_SOURCE_NAME; + case "getDatabaseSystem" -> DatabaseSystem.H2; + case "isOfType" -> args[0] == DatabaseSystem.H2; + case "getTransactionManager" -> Optional.empty(); + case "isInUse" -> Boolean.FALSE; + // The slice owns the database's lifecycle; nothing it runs may close it or swap its + // transaction manager. + case "close", "setTransactionManager" -> null; + case "toString" -> "IntentSlice " + DATA_SOURCE_NAME + " [" + h2.getURL() + "]"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> delegate(h2, method, args); + }; + } + } + + /** A JDBC connection that also answers which database system it talks to. */ + private static final class ConnectionHandler implements InvocationHandler { + + private final Connection connection; + + private ConnectionHandler(Connection connection) { + this.connection = connection; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return switch (method.getName()) { + case "getDatabaseSystem" -> DatabaseSystem.H2; + case "isOfType" -> args[0] == DatabaseSystem.H2; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> delegate(connection, method, args); + }; + } + } +} diff --git a/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceRuntime.java b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceRuntime.java new file mode 100644 index 00000000000..fcc736f3899 --- /dev/null +++ b/tests/tests-sdk/src/main/java/org/eclipse/dirigible/sdk/test/SliceRuntime.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.sdk.test; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import jakarta.jms.JMSException; + +import org.eclipse.dirigible.components.api.messaging.IntentSliceMessaging; +import org.eclipse.dirigible.components.base.spring.BeanProvider; +import org.eclipse.dirigible.components.base.tenant.Tenant; +import org.eclipse.dirigible.components.base.tenant.TenantContext; +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; +import org.eclipse.dirigible.components.data.store.config.CurrentTenantIdentifierResolverImpl; +import org.eclipse.dirigible.components.data.store.config.MultiTenantConnectionProviderImpl; +import org.eclipse.dirigible.components.data.store.java.manager.IntentSliceEntities; +import org.eclipse.dirigible.components.data.store.java.manager.JavaEntityManager; +import org.eclipse.dirigible.components.data.store.java.outbox.IntentSliceOutbox; +import org.eclipse.dirigible.components.data.store.java.store.JavaEntityStore; +import org.eclipse.dirigible.components.engine.numbering.DocumentNumberService; +import org.eclipse.dirigible.components.engine.numbering.IntentSliceNumbering; +import org.eclipse.dirigible.components.listeners.service.MessageConsumer; +import org.eclipse.dirigible.components.listeners.service.MessageProducer; +import org.eclipse.dirigible.engine.java.component.ComponentContainer; +import org.eclipse.dirigible.engine.java.runtime.ClientBeansHolder; +import org.eclipse.dirigible.engine.java.spi.LoadedClass; +import org.eclipse.dirigible.sdk.db.Entity; +import org.mockito.stubbing.Answer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.GenericApplicationContext; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * One project's application, running in the test JVM: its classes from the test classpath, its + * entities on a private H2 database, its components in the client bean container, and the platform + * services they reach through {@code Beans}/{@code BeanProvider} - the entity store, document + * numbering, the tenant - in a context holding nothing else. Built once per JVM and project, reset + * before each test. + */ +final class SliceRuntime implements AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(SliceRuntime.class); + + private static final String NUMBERS_EXTENSION = ".numbers"; + private static final String BUILD_FOLDER = "target"; + + private final SliceDatabase database = new SliceDatabase(); + private final List sent = Collections.synchronizedList(new ArrayList<>()); + private final MessageConsumer messageConsumer; + private final MessageProducer messageProducer; + private final JavaEntityManager entityManager; + private final DocumentNumberService numbering; + private final Map numberSeries; + private final GenericApplicationContext context = new GenericApplicationContext(); + private final ComponentContainer container; + private final Slice slice; + + SliceRuntime(Path projectRoot) { + LOGGER.info("Starting the IntentSlice of [{}]", projectRoot); + long started = System.nanoTime(); + DataSourcesManager dataSourcesManager = mock(DataSourcesManager.class); + when(dataSourcesManager.getDefaultDataSource()).thenReturn(database.dataSource()); + TenantContext tenantContext = new SingleTenantContext(); + entityManager = + new JavaEntityManager(dataSourcesManager, new MultiTenantConnectionProviderImpl(dataSourcesManager, database.dataSource()), + new CurrentTenantIdentifierResolverImpl(tenantContext)); + JavaEntityStore store = new JavaEntityStore(entityManager, IntentSliceOutbox.create(dataSourcesManager, tenantContext)); + numbering = IntentSliceNumbering.create(dataSourcesManager); + numberSeries = readNumberSeries(projectRoot); + messageConsumer = mock(MessageConsumer.class, invocation -> { + throw new UnsupportedOperationException("The IntentSlice has no broker to receive messages from"); + }); + messageProducer = recordingProducer(); + ClientBeansHolder beansHolder = new ClientBeansHolder(); + + context.registerBean(JavaEntityStore.class, () -> store); + context.registerBean(DocumentNumberService.class, () -> numbering); + context.registerBean(ClientBeansHolder.class, () -> beansHolder); + context.registerBean(TenantContext.class, () -> tenantContext); + context.registerBean(Tenant.class, () -> SingleTenantContext.DEFAULT_TENANT); + context.registerBean(DataSourcesManager.class, () -> dataSourcesManager); + context.refresh(); + activate(); + + String project = projectRoot.getFileName() + .toString(); + List classes = ProjectClasses.load(projectRoot, Thread.currentThread() + .getContextClassLoader()) + .stream() + .map(type -> new LoadedClass(project, type.getName(), type, type.getClassLoader())) + .toList(); + Map> entities = new LinkedHashMap<>(); + classes.stream() + .filter(loaded -> loaded.type() + .isAnnotationPresent(Entity.class)) + .forEach(loaded -> entities.put(project + "::" + loaded.fqn(), loaded.type())); + IntentSliceEntities.registerAll(entityManager, entities); + + container = new ComponentContainer(beansHolder); + container.rebuild(classes); + container.wiringWarnings() + .forEach((bean, warning) -> LOGGER.warn("IntentSlice wiring warning on [{}]: {}", bean, warning)); + if (!container.wiringErrors() + .isEmpty()) { + close(); + throw new IllegalStateException("The application's components could not be wired: " + container.wiringErrors()); + } + slice = new Slice(this); + LOGGER.info("IntentSlice of [{}] started in [{}] ms: [{}] classes, [{}] entities", projectRoot, + (System.nanoTime() - started) / 1_000_000, classes.size(), entities.size()); + } + + /** @return the test-facing handle of this runtime */ + Slice slice() { + return slice; + } + + /** @return the client bean container holding the application's components */ + ComponentContainer container() { + return container; + } + + /** @return every message sent since the last reset, in order */ + List sent() { + synchronized (sent) { + return List.copyOf(sent); + } + } + + /** + * Makes this runtime the one the application's static entry points reach - {@code Beans}, + * {@code BeanProvider}, the messaging facade. Several projects may be sliced in one JVM; the test + * about to run decides which one is live. + */ + void activate() { + new BeanProvider().setApplicationContext(context); + IntentSliceMessaging.install(messageConsumer, messageProducer); + } + + /** + * Puts the application back into the state of a fresh tenant: no rows, identities restarting at 1, + * every declared number series at its first value, no messages sent. + */ + void reset() { + try { + database.clear(); + for (NumberSeries series : numberSeries.values()) { + numbering.provision(series.name(), series.prefix(), series.size()); + } + } catch (SQLException ex) { + throw new IllegalStateException("Failed to reset the IntentSlice database", ex); + } + sent.clear(); + } + + @Override + public void close() { + entityManager.destroy(); + context.close(); + try { + database.close(); + } catch (SQLException ex) { + LOGGER.warn("Failed to shut the IntentSlice database down", ex); + } + } + + private MessageProducer recordingProducer() { + MessageProducer producer = mock(MessageProducer.class); + Answer record = invocation -> sent.add(new SentMessage(invocation.getArgument(0), invocation.getArgument(1))); + try { + doAnswer(record).when(producer) + .sendMessageToTopic(anyString(), anyString()); + doAnswer(record).when(producer) + .sendMessageToQueue(anyString(), anyString()); + } catch (JMSException ex) { + throw new IllegalStateException("Failed to stub the IntentSlice message producer", ex); + } + return producer; + } + + /** + * The number series the project declares in its {@code .numbers} files, which the platform's + * synchronizer would provision on publish. + */ + private static Map readNumberSeries(Path projectRoot) { + Map series = new LinkedHashMap<>(); + try (Stream files = Files.walk(projectRoot)) { + for (Path file : files.filter(path -> !path.startsWith(projectRoot.resolve(BUILD_FOLDER))) + .filter(path -> path.getFileName() + .toString() + .endsWith(NUMBERS_EXTENSION)) + .toList()) { + JsonObject declarations = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)) + .getAsJsonObject(); + for (JsonElement element : declarations.getAsJsonArray("series")) { + JsonObject declaration = element.getAsJsonObject(); + String name = declaration.get("name") + .getAsString(); + String prefix = Optional.ofNullable(declaration.get("prefix")) + .map(JsonElement::getAsString) + .orElse(""); + series.put(name, new NumberSeries(name, prefix, declaration.get("size") + .getAsInt())); + } + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read the number series of [" + projectRoot + "]", ex); + } + return series; + } + + /** One declared document-number series. */ + private record NumberSeries(String name, String prefix, int size) { + } +}