From bc5a350bac4b83fa86e835b8149184fa20ce1c01 Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Tue, 8 Sep 2026 14:34:23 +0200 Subject: [PATCH 1/2] fix: return error bodies for v3/v4 409/404 responses (API contract) v3/v4 threw entity-less WebApplicationException/NotFoundException; features assert "SKU already exists"/"Product not found" substrings; messages mirror the v5 mappers (text/plain). Evidence: GHA run 34215125166 (9 failures, empty bodies on v4). --- .../org/acme/service/v3/ProductServiceV3.java | 13 ++++++++++--- .../org/acme/service/v4/ProductServiceV4.java | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/acme/service/v3/ProductServiceV3.java b/src/main/java/org/acme/service/v3/ProductServiceV3.java index 5d44e42..953fdaa 100644 --- a/src/main/java/org/acme/service/v3/ProductServiceV3.java +++ b/src/main/java/org/acme/service/v3/ProductServiceV3.java @@ -7,6 +7,7 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.List; @@ -56,7 +57,11 @@ public List getAll() { */ public ProductEntity getBySku(String sku) { return repository.findBySku(sku) - .orElseThrow(() -> new NotFoundException("Product not found: " + sku)); + .orElseThrow(() -> new NotFoundException( + Response.status(Response.Status.NOT_FOUND) + .entity("Product not found: " + sku) + .type(MediaType.TEXT_PLAIN_TYPE) + .build())); } /** @@ -76,8 +81,10 @@ public ProductEntity create(ProductEntity product) { // Since Repository returns Optional, we check presence. if (repository.findBySku(product.getSku()).isPresent()) { throw new WebApplicationException( - "Product with this SKU already exists", - Response.Status.CONFLICT); + Response.status(Response.Status.CONFLICT) + .entity("Product SKU already exists: " + product.getSku()) + .type(MediaType.TEXT_PLAIN_TYPE) + .build()); } return repository.persist(product); } diff --git a/src/main/java/org/acme/service/v4/ProductServiceV4.java b/src/main/java/org/acme/service/v4/ProductServiceV4.java index e9a19c9..08b9226 100644 --- a/src/main/java/org/acme/service/v4/ProductServiceV4.java +++ b/src/main/java/org/acme/service/v4/ProductServiceV4.java @@ -10,6 +10,7 @@ import jakarta.transaction.Transactional; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.List; @@ -52,7 +53,11 @@ public List getAll() { public ProductDTO getBySku(String sku) { return repository.findBySku(sku) .map(ProductMapper::toDto) - .orElseThrow(() -> new NotFoundException("Product with SKU " + sku + " not found")); + .orElseThrow(() -> new NotFoundException( + Response.status(Response.Status.NOT_FOUND) + .entity("Product not found: " + sku) + .type(MediaType.TEXT_PLAIN_TYPE) + .build())); } /** @@ -72,8 +77,10 @@ public ProductDTO create(CreateProductRequest request) { // Step 2: Check persistence integrity if (repository.findBySku(entity.getSku()).isPresent()) { throw new WebApplicationException( - "Product with this SKU already exists", - Response.Status.CONFLICT); + Response.status(Response.Status.CONFLICT) + .entity("Product SKU already exists: " + entity.getSku()) + .type(MediaType.TEXT_PLAIN_TYPE) + .build()); } // Step 3: Persist and Return DTO @@ -90,7 +97,11 @@ public void delete(String sku) { .ifPresentOrElse( repository::delete, () -> { - throw new NotFoundException("Product SKU " + sku + " not found"); + throw new NotFoundException( + Response.status(Response.Status.NOT_FOUND) + .entity("Product not found: " + sku) + .type(MediaType.TEXT_PLAIN_TYPE) + .build()); }); } From 3f977b79c5ac48dec2a75f1c321688b5d01452cb Mon Sep 17 00:00:00 2001 From: Emmanuel Bruno Date: Tue, 8 Sep 2026 15:30:46 +0200 Subject: [PATCH 2/2] test: pin API version deterministically per acceptance IT class the five tag-based @Before hooks in Hooks.java overwrote VersionContext in nondeterministic order, so V3/V4 IT classes exercised a random version (usually v5); v3/v4 were never actually tested; each IT class now pins its own version. --- .../org/acme/api/cucumber/steps/Hooks.java | 46 ------------------- .../cucumber/v1/CatalogV1AcceptanceIT.java | 5 +- .../acme/api/cucumber/v1/V1VersionHook.java | 27 +++++++++++ .../cucumber/v2/CatalogV2AcceptanceIT.java | 5 +- .../acme/api/cucumber/v2/V2VersionHook.java | 27 +++++++++++ .../cucumber/v3/CatalogV3AcceptanceIT.java | 5 +- .../acme/api/cucumber/v3/V3VersionHook.java | 27 +++++++++++ .../cucumber/v4/CatalogV4AcceptanceIT.java | 5 +- .../acme/api/cucumber/v4/V4VersionHook.java | 27 +++++++++++ .../cucumber/v5/CatalogV5AcceptanceIT.java | 5 +- .../acme/api/cucumber/v5/V5VersionHook.java | 27 +++++++++++ 11 files changed, 155 insertions(+), 51 deletions(-) delete mode 100644 src/test/java/org/acme/api/cucumber/steps/Hooks.java create mode 100644 src/test/java/org/acme/api/cucumber/v1/V1VersionHook.java create mode 100644 src/test/java/org/acme/api/cucumber/v2/V2VersionHook.java create mode 100644 src/test/java/org/acme/api/cucumber/v3/V3VersionHook.java create mode 100644 src/test/java/org/acme/api/cucumber/v4/V4VersionHook.java create mode 100644 src/test/java/org/acme/api/cucumber/v5/V5VersionHook.java diff --git a/src/test/java/org/acme/api/cucumber/steps/Hooks.java b/src/test/java/org/acme/api/cucumber/steps/Hooks.java deleted file mode 100644 index 5d5168d..0000000 --- a/src/test/java/org/acme/api/cucumber/steps/Hooks.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.acme.api.cucumber.steps; - -import io.cucumber.java.Before; -import jakarta.enterprise.context.Dependent; -import jakarta.inject.Inject; -import org.acme.api.cucumber.VersionContext; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Dependent -public class Hooks { - private static final Logger LOGGER = LoggerFactory.getLogger(Hooks.class); - - @Inject - VersionContext versionContext; - - @Before("@v1") - public void setV1() { - LOGGER.info("Setting API Version to v1 based on tag"); - versionContext.setVersion("v1"); - } - - @Before("@v2") - public void setV2() { - LOGGER.info("Setting API Version to v2 based on tag"); - versionContext.setVersion("v2"); - } - - @Before("@v3") - public void setV3() { - LOGGER.info("Setting API Version to v3 based on tag"); - versionContext.setVersion("v3"); - } - - @Before("@v4") - public void setV4() { - LOGGER.info("Setting API Version to v4 based on tag"); - versionContext.setVersion("v4"); - } - - @Before("@v5") - public void setV5() { - LOGGER.info("Setting API Version to v5 based on tag"); - versionContext.setVersion("v5"); - } -} diff --git a/src/test/java/org/acme/api/cucumber/v1/CatalogV1AcceptanceIT.java b/src/test/java/org/acme/api/cucumber/v1/CatalogV1AcceptanceIT.java index 52e487a..045bfb9 100644 --- a/src/test/java/org/acme/api/cucumber/v1/CatalogV1AcceptanceIT.java +++ b/src/test/java/org/acme/api/cucumber/v1/CatalogV1AcceptanceIT.java @@ -6,6 +6,9 @@ import io.quarkiverse.cucumber.CucumberOptions; @QuarkusTestResource(PostgreSqlTestResource.class) -@CucumberOptions(features = "classpath:features/v1", glue = "org.acme.api.cucumber.steps") +@CucumberOptions(features = "classpath:features/v1", glue = { + "org.acme.api.cucumber.steps", + "org.acme.api.cucumber.v1" +}) public class CatalogV1AcceptanceIT extends CucumberQuarkusTest { } diff --git a/src/test/java/org/acme/api/cucumber/v1/V1VersionHook.java b/src/test/java/org/acme/api/cucumber/v1/V1VersionHook.java new file mode 100644 index 0000000..bf2e8bf --- /dev/null +++ b/src/test/java/org/acme/api/cucumber/v1/V1VersionHook.java @@ -0,0 +1,27 @@ +package org.acme.api.cucumber.v1; + +import io.cucumber.java.Before; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.acme.api.cucumber.VersionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Pins the API version to v1 for every scenario of {@code CatalogV1AcceptanceIT}. + * Being part of this IT class's glue only, it runs deterministically before each + * of its scenarios and is the single place that sets the version for this class. + */ +@Dependent +public class V1VersionHook { + private static final Logger LOGGER = LoggerFactory.getLogger(V1VersionHook.class); + + @Inject + VersionContext versionContext; + + @Before + public void pinVersion() { + LOGGER.info("Setting API Version to v1 for this acceptance test class"); + versionContext.setVersion("v1"); + } +} diff --git a/src/test/java/org/acme/api/cucumber/v2/CatalogV2AcceptanceIT.java b/src/test/java/org/acme/api/cucumber/v2/CatalogV2AcceptanceIT.java index a8a2aaf..a53f911 100644 --- a/src/test/java/org/acme/api/cucumber/v2/CatalogV2AcceptanceIT.java +++ b/src/test/java/org/acme/api/cucumber/v2/CatalogV2AcceptanceIT.java @@ -7,6 +7,9 @@ import io.quarkiverse.cucumber.CucumberOptions; @QuarkusTestResource(PostgreSqlTestResource.class) -@CucumberOptions(features = "classpath:features/v2", glue = "org.acme.api.cucumber.steps") +@CucumberOptions(features = "classpath:features/v2", glue = { + "org.acme.api.cucumber.steps", + "org.acme.api.cucumber.v2" +}) public class CatalogV2AcceptanceIT extends CucumberQuarkusTest { } diff --git a/src/test/java/org/acme/api/cucumber/v2/V2VersionHook.java b/src/test/java/org/acme/api/cucumber/v2/V2VersionHook.java new file mode 100644 index 0000000..673bfb6 --- /dev/null +++ b/src/test/java/org/acme/api/cucumber/v2/V2VersionHook.java @@ -0,0 +1,27 @@ +package org.acme.api.cucumber.v2; + +import io.cucumber.java.Before; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.acme.api.cucumber.VersionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Pins the API version to v2 for every scenario of {@code CatalogV2AcceptanceIT}. + * Being part of this IT class's glue only, it runs deterministically before each + * of its scenarios and is the single place that sets the version for this class. + */ +@Dependent +public class V2VersionHook { + private static final Logger LOGGER = LoggerFactory.getLogger(V2VersionHook.class); + + @Inject + VersionContext versionContext; + + @Before + public void pinVersion() { + LOGGER.info("Setting API Version to v2 for this acceptance test class"); + versionContext.setVersion("v2"); + } +} diff --git a/src/test/java/org/acme/api/cucumber/v3/CatalogV3AcceptanceIT.java b/src/test/java/org/acme/api/cucumber/v3/CatalogV3AcceptanceIT.java index 3666f60..f1d32d4 100644 --- a/src/test/java/org/acme/api/cucumber/v3/CatalogV3AcceptanceIT.java +++ b/src/test/java/org/acme/api/cucumber/v3/CatalogV3AcceptanceIT.java @@ -9,6 +9,9 @@ @CucumberOptions(features = { "classpath:features/catalog", "classpath:features/v3" -}, tags = "@v3", glue = "org.acme.api.cucumber.steps") +}, tags = "@v3", glue = { + "org.acme.api.cucumber.steps", + "org.acme.api.cucumber.v3" +}) public class CatalogV3AcceptanceIT extends CucumberQuarkusTest { } diff --git a/src/test/java/org/acme/api/cucumber/v3/V3VersionHook.java b/src/test/java/org/acme/api/cucumber/v3/V3VersionHook.java new file mode 100644 index 0000000..b1234ae --- /dev/null +++ b/src/test/java/org/acme/api/cucumber/v3/V3VersionHook.java @@ -0,0 +1,27 @@ +package org.acme.api.cucumber.v3; + +import io.cucumber.java.Before; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.acme.api.cucumber.VersionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Pins the API version to v3 for every scenario of {@code CatalogV3AcceptanceIT}. + * Being part of this IT class's glue only, it runs deterministically before each + * of its scenarios and is the single place that sets the version for this class. + */ +@Dependent +public class V3VersionHook { + private static final Logger LOGGER = LoggerFactory.getLogger(V3VersionHook.class); + + @Inject + VersionContext versionContext; + + @Before + public void pinVersion() { + LOGGER.info("Setting API Version to v3 for this acceptance test class"); + versionContext.setVersion("v3"); + } +} diff --git a/src/test/java/org/acme/api/cucumber/v4/CatalogV4AcceptanceIT.java b/src/test/java/org/acme/api/cucumber/v4/CatalogV4AcceptanceIT.java index e7ae977..a500c66 100644 --- a/src/test/java/org/acme/api/cucumber/v4/CatalogV4AcceptanceIT.java +++ b/src/test/java/org/acme/api/cucumber/v4/CatalogV4AcceptanceIT.java @@ -9,6 +9,9 @@ @CucumberOptions(features = { "classpath:features/catalog", "classpath:features/v4" -}, tags = "@v4", glue = "org.acme.api.cucumber.steps") +}, tags = "@v4", glue = { + "org.acme.api.cucumber.steps", + "org.acme.api.cucumber.v4" +}) public class CatalogV4AcceptanceIT extends CucumberQuarkusTest { } diff --git a/src/test/java/org/acme/api/cucumber/v4/V4VersionHook.java b/src/test/java/org/acme/api/cucumber/v4/V4VersionHook.java new file mode 100644 index 0000000..9d9ec98 --- /dev/null +++ b/src/test/java/org/acme/api/cucumber/v4/V4VersionHook.java @@ -0,0 +1,27 @@ +package org.acme.api.cucumber.v4; + +import io.cucumber.java.Before; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.acme.api.cucumber.VersionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Pins the API version to v4 for every scenario of {@code CatalogV4AcceptanceIT}. + * Being part of this IT class's glue only, it runs deterministically before each + * of its scenarios and is the single place that sets the version for this class. + */ +@Dependent +public class V4VersionHook { + private static final Logger LOGGER = LoggerFactory.getLogger(V4VersionHook.class); + + @Inject + VersionContext versionContext; + + @Before + public void pinVersion() { + LOGGER.info("Setting API Version to v4 for this acceptance test class"); + versionContext.setVersion("v4"); + } +} diff --git a/src/test/java/org/acme/api/cucumber/v5/CatalogV5AcceptanceIT.java b/src/test/java/org/acme/api/cucumber/v5/CatalogV5AcceptanceIT.java index a3743db..be952d6 100644 --- a/src/test/java/org/acme/api/cucumber/v5/CatalogV5AcceptanceIT.java +++ b/src/test/java/org/acme/api/cucumber/v5/CatalogV5AcceptanceIT.java @@ -9,6 +9,9 @@ @CucumberOptions(features = { "classpath:features/catalog", "classpath:features/v5" -}, tags = "@v5", glue = "org.acme.api.cucumber.steps") +}, tags = "@v5", glue = { + "org.acme.api.cucumber.steps", + "org.acme.api.cucumber.v5" +}) public class CatalogV5AcceptanceIT extends CucumberQuarkusTest { } diff --git a/src/test/java/org/acme/api/cucumber/v5/V5VersionHook.java b/src/test/java/org/acme/api/cucumber/v5/V5VersionHook.java new file mode 100644 index 0000000..8fb10b6 --- /dev/null +++ b/src/test/java/org/acme/api/cucumber/v5/V5VersionHook.java @@ -0,0 +1,27 @@ +package org.acme.api.cucumber.v5; + +import io.cucumber.java.Before; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.acme.api.cucumber.VersionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Pins the API version to v5 for every scenario of {@code CatalogV5AcceptanceIT}. + * Being part of this IT class's glue only, it runs deterministically before each + * of its scenarios and is the single place that sets the version for this class. + */ +@Dependent +public class V5VersionHook { + private static final Logger LOGGER = LoggerFactory.getLogger(V5VersionHook.class); + + @Inject + VersionContext versionContext; + + @Before + public void pinVersion() { + LOGGER.info("Setting API Version to v5 for this acceptance test class"); + versionContext.setVersion("v5"); + } +}