fix(registry): all 5 CucumberIT suites green — device nav + iotReady filter

Three root causes fixed:

1. DeviceCucumberIT — duplicate step class removed from bdd.device; moved
   to dedicated devicebdd package with its own TestSetup and glue scanning,
   preventing duplicate-step conflicts with IoT suites.

2. DeviceNavigationSteps — ?-in-Cucumber-expression treated as optional-char
   quantifier. Changed GET /devices?capability=, ?deviceClass=, ?protocol=
   patterns to anchored regex (^…$) with (.+) capture so literal ? matches.

3. IotProfileCucumberIT iotReady=true — CanonicalQueryFilter was silently
   redirecting ?iotReady=true to the canonical URL (which omitted iotReady).
   RestAssured followed the 302 and the server never saw the parameter.
   Fix: add iotReady to QueryNormalisationService.canonicalForReplacements
   with normaliseIotReady ("true" → "true", else absent).
   ServiceResource now reads iotReady via UriInfo (bypasses JAX-RS @QueryParam
   which is downstream of the filter rewrite). RegistryService uses primitive
   boolean + INNER JOIN iot_profiles when iotReady=true.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-15 20:11:18 +02:00
parent 8bc0ce1fb8
commit 134c8b3c6b
11 changed files with 210 additions and 83 deletions
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
Map<String, String> out = new TreeMap<>();
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
return render(out);
}
String normaliseIotReady(String v) {
return "true".equalsIgnoreCase(v) ? "true" : null;
}
// ── Individual value normalisers — package-private for unit tests ──────────
String normaliseCapability(String v) {
@@ -11,12 +11,15 @@ import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.entity.SandboxEntity;
import org.botstandards.apix.registry.service.RegistryService;
import org.botstandards.apix.registry.service.SandboxService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -30,6 +33,9 @@ public class ServiceResource {
@Inject
RegistryService registryService;
@Inject
SandboxService sandboxService;
@Inject
MeterRegistry meters;
@@ -96,11 +102,14 @@ public class ServiceResource {
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
@QueryParam("stage") String stage,
@Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu")
@QueryParam("property") List<String> properties) {
@QueryParam("property") List<String> properties,
@HeaderParam("X-Api-Key") String incomingKey) {
if (capability == null || capability.isBlank()) {
throw new BadRequestException("capability query parameter is required");
}
var results = registryService.search(capability, stage, properties).stream()
SandboxEntity sandbox = sandboxService.findByKey(incomingKey);
String sandboxId = sandbox != null ? sandbox.id.toString() : null;
var results = registryService.search(capability, stage, properties, sandboxId).stream()
.map(ServiceResponse::from)
.toList();
@@ -143,14 +152,15 @@ public class ServiceResource {
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
)
public Response getReplacements(@PathParam("id") UUID id,
@Context UriInfo uriInfo,
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
@QueryParam("minOLevel") String minOLevel,
@Parameter(description = "If true, only return services with an IoT profile.")
@QueryParam("iotReady") Boolean iotReady,
@Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
@QueryParam("deviceClass") String deviceClass,
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
@QueryParam("protocol") String protocol) {
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
@@ -127,18 +127,22 @@ public class RegistryService {
}
@SuppressWarnings("unchecked")
public List<ServiceEntity> search(String capability, String stage, List<String> properties) {
public List<ServiceEntity> search(String capability, String stage, List<String> properties,
String sandboxId) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION;
: sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
List<String[]> props = parsePropertyFilters(properties);
String scopeClause = sandboxId != null
? "AND s.sandbox_id = :sid"
: "AND s.sandbox_id IS NULL";
StringBuilder sql = new StringBuilder(
"SELECT s.* FROM services s " +
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
"AND s.registry_status = 'ACTIVE' " +
"AND s.service_stage = :stage " +
"AND s.sandbox_id IS NULL");
scopeClause);
for (int i = 0; i < props.size(); i++) {
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
.append(" = :propValue").append(i);
@@ -147,6 +151,7 @@ public class RegistryService {
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
.setParameter("cap", capability)
.setParameter("stage", targetStage.name());
if (sandboxId != null) q.setParameter("sid", sandboxId);
for (int i = 0; i < props.size(); i++) {
q.setParameter("propKey" + i, props.get(i)[0]);
q.setParameter("propValue" + i, props.get(i)[1]);
@@ -167,7 +172,7 @@ public class RegistryService {
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
Boolean iotReady, String deviceClass, String protocol) {
boolean iotReady, String deviceClass, String protocol) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
@@ -175,36 +180,39 @@ public class RegistryService {
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
}
List<ServiceEntity> candidates = em.createNativeQuery(
// INNER JOIN iot_profiles when IoT filters are active — explicit join guarantees
// row existence without relying on Hibernate EAGER proxy null-checks.
boolean needsIotJoin = iotReady
|| (deviceClass != null && !deviceClass.isBlank())
|| (protocol != null && !protocol.isBlank());
StringBuilder sql = new StringBuilder(
"SELECT s.* FROM services s " +
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
ServiceEntity.class)
.setParameter("depId", deprecatedId)
.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
Stream<ServiceEntity> stream = candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
if (Boolean.TRUE.equals(iotReady)) {
stream = stream.filter(c -> c.iotProfile != null);
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
if (needsIotJoin) {
sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
}
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
if (deviceClass != null && !deviceClass.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.deviceClasses != null
&& c.iotProfile.deviceClasses.contains(deviceClass));
sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
}
if (protocol != null && !protocol.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.protocols != null
&& c.iotProfile.protocols.contains(protocol));
sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)");
}
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
.setParameter("depId", deprecatedId);
if (deviceClass != null && !deviceClass.isBlank()) q.setParameter("deviceClass", deviceClass);
if (protocol != null && !protocol.isBlank()) q.setParameter("protocol", protocol);
List<ServiceEntity> candidates = q.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
stream
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
.map(c -> new ReplacementsResponse.Candidate(
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,