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:
+10
-9
@@ -453,6 +453,13 @@ public class IotTransitionSteps {
|
|||||||
lastResponse = given().get("/services/" + currentServiceId + "/history");
|
lastResponse = given().get("/services/" + currentServiceId + "/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Then — HTTP status assertion ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Then("the response is HTTP {int}")
|
||||||
|
public void theResponseIsHttp(int status) {
|
||||||
|
lastResponse.then().statusCode(status);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Then — lifecycle state assertions ────────────────────────────────────
|
// ── Then — lifecycle state assertions ────────────────────────────────────
|
||||||
|
|
||||||
@Then("the service stage is {string}")
|
@Then("the service stage is {string}")
|
||||||
@@ -511,13 +518,6 @@ public class IotTransitionSteps {
|
|||||||
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
|
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Then — HTTP response assertions ──────────────────────────────────────
|
|
||||||
|
|
||||||
@Then("the response is HTTP {int}")
|
|
||||||
public void theResponseIsHttp(int statusCode) {
|
|
||||||
lastResponse.then().statusCode(statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Then("the error message contains {string}")
|
@Then("the error message contains {string}")
|
||||||
public void theErrorMessageContains(String message) {
|
public void theErrorMessageContains(String message) {
|
||||||
lastResponse.then().body("message", containsString(message));
|
lastResponse.then().body("message", containsString(message));
|
||||||
@@ -861,8 +861,9 @@ public class IotTransitionSteps {
|
|||||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
|
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
|
||||||
public void getReplacementsIotReady() {
|
public void getReplacementsIotReady() {
|
||||||
actionPhase = true;
|
actionPhase = true;
|
||||||
lastResponse = given().get(
|
lastResponse = given()
|
||||||
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
|
.queryParam("iotReady", "true")
|
||||||
|
.get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
||||||
|
|||||||
+77
-28
@@ -33,7 +33,9 @@ public class SandboxSteps {
|
|||||||
/** Name of the most recently created sandbox. */
|
/** Name of the most recently created sandbox. */
|
||||||
private String currentSandboxName;
|
private String currentSandboxName;
|
||||||
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
|
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
|
||||||
private final Map<String, String> sandboxKeys = new HashMap<>();
|
private final Map<String, String> sandboxKeys = new HashMap<>();
|
||||||
|
/** UUIDs indexed by sandbox name — used for resource routing (name is display-only). */
|
||||||
|
private final Map<String, String> sandboxUuids = new HashMap<>();
|
||||||
|
|
||||||
// ── Given — sandbox creation ──────────────────────────────────────────────
|
// ── Given — sandbox creation ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ public class SandboxSteps {
|
|||||||
@Given("a production service {string} with endpoint {string} is registered")
|
@Given("a production service {string} with endpoint {string} is registered")
|
||||||
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
|
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
|
||||||
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
|
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
|
||||||
|
payload.put("serviceStage", "PRODUCTION");
|
||||||
given()
|
given()
|
||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
|
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
|
||||||
@@ -57,14 +60,15 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
|
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
|
||||||
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
|
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
||||||
given()
|
given()
|
||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,8 @@ public class SandboxSteps {
|
|||||||
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
||||||
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
||||||
String extension, String sandboxName) {
|
String extension, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
int colon = extension.indexOf(':');
|
int colon = extension.indexOf(':');
|
||||||
Map<String, Object> extensions = colon > 0
|
Map<String, Object> extensions = colon > 0
|
||||||
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
|
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
|
||||||
@@ -84,19 +89,19 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("the sandbox root for {string} has been viewed once")
|
@Given("the sandbox root for {string} has been viewed once")
|
||||||
public void sandboxRootHasBeenViewedOnce(String sandboxName) {
|
public void sandboxRootHasBeenViewedOnce(String sandboxName) {
|
||||||
given().get("/sandbox/" + sandboxName).then().statusCode(200);
|
given().get("/sandbox/" + resolveUuid(sandboxName)).then().statusCode(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("the sandbox service list for {string} has been requested once")
|
@Given("the sandbox service list for {string} has been requested once")
|
||||||
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
|
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
|
||||||
given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200);
|
given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").then().statusCode(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
|
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
|
||||||
@@ -111,7 +116,8 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@Given("{int} services have been registered in sandbox {string}")
|
@Given("{int} services have been registered in sandbox {string}")
|
||||||
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
|
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
Map<String, Object> payload = buildServicePayload(
|
Map<String, Object> payload = buildServicePayload(
|
||||||
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
|
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
|
||||||
@@ -120,7 +126,7 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,7 @@ public class SandboxSteps {
|
|||||||
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
|
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
|
||||||
currentSandboxName = name;
|
currentSandboxName = name;
|
||||||
sandboxKeys.put(name, currentSandboxKey);
|
sandboxKeys.put(name, currentSandboxKey);
|
||||||
|
sandboxUuids.put(name, lastResponse.jsonPath().getString("sandboxId"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,19 +179,29 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@When("the sandbox root for {string} is requested")
|
@When("the sandbox root for {string} is requested")
|
||||||
public void sandboxRootRequested(String sandboxName) {
|
public void sandboxRootRequested(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName).andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName)).andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("the sandbox root for UUID {string} is requested")
|
||||||
|
public void sandboxRootRequestedByUuid(String uuid) {
|
||||||
|
lastResponse = given().get("/sandbox/" + uuid).andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── When — service operations ─────────────────────────────────────────────
|
// ── When — service operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /services is called without authentication")
|
@When("GET \\/services is called without authentication")
|
||||||
public void getServicesWithoutAuth() {
|
public void getServicesWithoutAuth() {
|
||||||
lastResponse = given().get("/services").andReturn();
|
lastResponse = given().get("/services").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} without authentication")
|
||||||
|
public void getServicesWithCapabilityWithoutAuth(String capability) {
|
||||||
|
lastResponse = given().get("/services?capability=" + capability).andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
@When("the sandbox service list for {string} is requested")
|
@When("the sandbox service list for {string} is requested")
|
||||||
public void sandboxServiceListRequested(String sandboxName) {
|
public void sandboxServiceListRequested(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("a service is registered in sandbox {string} without an API key")
|
@When("a service is registered in sandbox {string} without an API key")
|
||||||
@@ -194,7 +211,7 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +223,7 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,21 +236,38 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("sandbox {string} services are searched by capability {string}")
|
@When("sandbox {string} services are searched by capability {string}")
|
||||||
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
|
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability)
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability)
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("sandbox {string} services are searched by capability {string} and property {string}")
|
@When("sandbox {string} services are searched by capability {string} and property {string}")
|
||||||
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
|
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property)
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&property=" + property)
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} and the sandbox key for {string}")
|
||||||
|
public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) {
|
||||||
|
String key = resolveKey(sandboxName);
|
||||||
|
lastResponse = given()
|
||||||
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
|
.get("/services?capability=" + capability)
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} and API key {string}")
|
||||||
|
public void getServicesWithCapabilityAndLiteralKey(String capability, String apiKey) {
|
||||||
|
lastResponse = given()
|
||||||
|
.header(SANDBOX_API_KEY_HEADER, apiKey)
|
||||||
|
.get("/services?capability=" + capability)
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,17 +275,18 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@When("the telemetry for {string} is requested with the sandbox API key")
|
@When("the telemetry for {string} is requested with the sandbox API key")
|
||||||
public void telemetryRequestedWithKey(String sandboxName) {
|
public void telemetryRequestedWithKey(String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
.get("/sandbox/" + uuid + "/telemetry")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the telemetry for {string} is requested without an API key")
|
@When("the telemetry for {string} is requested without an API key")
|
||||||
public void telemetryRequestedWithoutKey(String sandboxName) {
|
public void telemetryRequestedWithoutKey(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/telemetry").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the telemetry for {string} is requested with API key {string}")
|
@When("the telemetry for {string} is requested with API key {string}")
|
||||||
@@ -259,13 +294,13 @@ public class SandboxSteps {
|
|||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── When — feedback ───────────────────────────────────────────────────────
|
// ── When — feedback ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /sandbox/feedback-schema is called without authentication")
|
@When("GET \\/sandbox\\/feedback-schema is called without authentication")
|
||||||
public void getFeedbackSchema() {
|
public void getFeedbackSchema() {
|
||||||
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
|
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
|
||||||
}
|
}
|
||||||
@@ -291,23 +326,24 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(Map.of("scores", Map.of()))
|
.body(Map.of("scores", Map.of()))
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/feedback")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the feedback aggregate for {string} is requested with the sandbox API key")
|
@When("the feedback aggregate for {string} is requested with the sandbox API key")
|
||||||
public void feedbackAggregateWithKey(String sandboxName) {
|
public void feedbackAggregateWithKey(String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/feedback")
|
.get("/sandbox/" + uuid + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the feedback aggregate for {string} is requested without an API key")
|
@When("the feedback aggregate for {string} is requested without an API key")
|
||||||
public void feedbackAggregateWithoutKey(String sandboxName) {
|
public void feedbackAggregateWithoutKey(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Then — HTTP status ────────────────────────────────────────────────────
|
// ── Then — HTTP status ────────────────────────────────────────────────────
|
||||||
@@ -339,6 +375,11 @@ public class SandboxSteps {
|
|||||||
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
|
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Then("the response contains _links.self")
|
||||||
|
public void responseContainsLinksSelf() {
|
||||||
|
assertThat(lastResponse.jsonPath().getString("_links.self.href")).as("_links.self.href").isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
@Then("the response contains _links.self ending with {string}")
|
@Then("the response contains _links.self ending with {string}")
|
||||||
public void responseContainsLinksSelfEndingWith(String suffix) {
|
public void responseContainsLinksSelfEndingWith(String suffix) {
|
||||||
String href = lastResponse.jsonPath().getString("_links.self.href");
|
String href = lastResponse.jsonPath().getString("_links.self.href");
|
||||||
@@ -510,8 +551,10 @@ public class SandboxSteps {
|
|||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
|
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
|
||||||
String key = r.jsonPath().getString("apiKey");
|
String key = r.jsonPath().getString("apiKey");
|
||||||
|
String uuid = r.jsonPath().getString("sandboxId");
|
||||||
sandboxKeys.put(name, key);
|
sandboxKeys.put(name, key);
|
||||||
|
sandboxUuids.put(name, uuid);
|
||||||
currentSandboxKey = key;
|
currentSandboxKey = key;
|
||||||
currentSandboxName = name;
|
currentSandboxName = name;
|
||||||
}
|
}
|
||||||
@@ -522,6 +565,12 @@ public class SandboxSteps {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveUuid(String sandboxName) {
|
||||||
|
String uuid = sandboxUuids.get(sandboxName);
|
||||||
|
assertThat(uuid).as("sandbox UUID for '%s' must be known — call createSandbox first", sandboxName).isNotNull();
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
|
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
|
||||||
submitFeedbackResponse(sandboxName, scores, model, provider);
|
submitFeedbackResponse(sandboxName, scores, model, provider);
|
||||||
}
|
}
|
||||||
@@ -536,7 +585,7 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(body)
|
.body(body)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/feedback")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
package org.botstandards.apix.registry.bdd.device;
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
import io.cucumber.core.cli.Main;
|
import io.cucumber.core.cli.Main;
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
@@ -9,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
/**
|
/**
|
||||||
* Runs all device navigation BDD scenarios inside the Quarkus test context.
|
* Runs all device navigation BDD scenarios inside the Quarkus test context.
|
||||||
*
|
*
|
||||||
* Uses its own glue package so step definitions do not conflict with
|
* Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd
|
||||||
* IotTransitionSteps or OrgOnboardingSteps.
|
* (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps
|
||||||
|
* from being picked up by non-device CucumberITs that only scan --glue bdd.
|
||||||
*/
|
*/
|
||||||
@QuarkusTest
|
@QuarkusTest
|
||||||
public class DeviceCucumberIT {
|
public class DeviceCucumberIT {
|
||||||
@@ -18,7 +19,7 @@ public class DeviceCucumberIT {
|
|||||||
@Test
|
@Test
|
||||||
public void run() {
|
public void run() {
|
||||||
byte exitCode = Main.run(
|
byte exitCode = Main.run(
|
||||||
"--glue", "org.botstandards.apix.registry.bdd.device",
|
"--glue", "org.botstandards.apix.registry.devicebdd",
|
||||||
"--plugin", "pretty",
|
"--plugin", "pretty",
|
||||||
"--plugin", "json:target/cucumber-report-devices.json",
|
"--plugin", "json:target/cucumber-report-devices.json",
|
||||||
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
|
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
|
||||||
+8
-7
@@ -1,4 +1,4 @@
|
|||||||
package org.botstandards.apix.registry.bdd.device;
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
import io.cucumber.java.en.And;
|
import io.cucumber.java.en.And;
|
||||||
import io.cucumber.java.en.Given;
|
import io.cucumber.java.en.Given;
|
||||||
@@ -16,7 +16,8 @@ import static org.hamcrest.Matchers.*;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-contained step definitions for the /devices top-level resource.
|
* Self-contained step definitions for the /devices top-level resource.
|
||||||
* Uses its own glue package so it does not conflict with IotTransitionSteps.
|
* Lives in a sibling package (devicebdd) to avoid recursive inclusion by
|
||||||
|
* non-device CucumberITs that use --glue bdd only.
|
||||||
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
|
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
|
||||||
*/
|
*/
|
||||||
public class DeviceNavigationSteps {
|
public class DeviceNavigationSteps {
|
||||||
@@ -97,27 +98,27 @@ public class DeviceNavigationSteps {
|
|||||||
|
|
||||||
// ── When ──────────────────────────────────────────────────────────────────
|
// ── When ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /devices is called with no query params")
|
@When("GET \\/devices is called with no query params")
|
||||||
public void getDevicesRoot() {
|
public void getDevicesRoot() {
|
||||||
lastResponse = given().get("/devices");
|
lastResponse = given().get("/devices");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET / is called")
|
@When("GET \\/ is called")
|
||||||
public void getRoot() {
|
public void getRoot() {
|
||||||
lastResponse = given().get("/");
|
lastResponse = given().get("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?capability={string} is called")
|
@When("^GET /devices\\?capability=(.+) is called$")
|
||||||
public void getDevicesByCapability(String capability) {
|
public void getDevicesByCapability(String capability) {
|
||||||
lastResponse = given().get("/devices?capability=" + capability);
|
lastResponse = given().get("/devices?capability=" + capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?deviceClass={string} is called")
|
@When("^GET /devices\\?deviceClass=(.+) is called$")
|
||||||
public void getDevicesByDeviceClass(String deviceClass) {
|
public void getDevicesByDeviceClass(String deviceClass) {
|
||||||
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
|
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?protocol={string} is called")
|
@When("^GET /devices\\?protocol=(.+) is called$")
|
||||||
public void getDevicesByProtocol(String protocol) {
|
public void getDevicesByProtocol(String protocol) {
|
||||||
lastResponse = given().get("/devices?protocol=" + protocol);
|
lastResponse = given().get("/devices?protocol=" + protocol);
|
||||||
}
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
|
import io.cucumber.java.After;
|
||||||
|
import io.cucumber.java.Before;
|
||||||
|
import io.quarkus.arc.Arc;
|
||||||
|
import io.restassured.RestAssured;
|
||||||
|
import org.botstandards.apix.registry.service.ClockService;
|
||||||
|
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class TestSetup {
|
||||||
|
|
||||||
|
private static final Instant REFERENCE_INSTANT = Instant.parse("2025-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
@Before(order = 0)
|
||||||
|
public void configureRestAssured() {
|
||||||
|
RestAssured.port = 8181;
|
||||||
|
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
|
||||||
|
Arc.container().instance(ClockService.class).get().advance(REFERENCE_INSTANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before(order = 1)
|
||||||
|
public void truncateTables() throws Exception {
|
||||||
|
try (var conn = DriverManager.getConnection(
|
||||||
|
"jdbc:postgresql://localhost:5432/apix", "apix", "apix");
|
||||||
|
var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void resetClock() {
|
||||||
|
Arc.container().instance(ClockService.class).get().reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution
|
|||||||
Scenario: Root resource with valid sandbox API key includes _links.sandbox
|
Scenario: Root resource with valid sandbox API key includes _links.sandbox
|
||||||
When the root resource is requested with the sandbox API key for "nav-demo"
|
When the root resource is requested with the sandbox API key for "nav-demo"
|
||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And the response contains _links.sandbox ending with "/sandbox/nav-demo"
|
And the response contains _links.sandbox
|
||||||
|
|
||||||
Scenario: Root resource with unknown API key omits _links.sandbox
|
Scenario: Root resource with unknown API key omits _links.sandbox
|
||||||
When the root resource is requested with API key "apix_sb_unknownkey"
|
When the root resource is requested with API key "apix_sb_unknownkey"
|
||||||
@@ -28,6 +28,6 @@ Feature: HATEOAS navigation and root key resolution
|
|||||||
And the response contains _links.submitFeedback
|
And the response contains _links.submitFeedback
|
||||||
And the response contains _links.feedbackSchema
|
And the response contains _links.feedbackSchema
|
||||||
|
|
||||||
Scenario: Sandbox root for unknown name returns 404
|
Scenario: Sandbox root for unknown UUID returns 404
|
||||||
When the sandbox root for "does-not-exist" is requested
|
When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested
|
||||||
Then the response code is 404
|
Then the response code is 404
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ Feature: Sandbox registration
|
|||||||
And the response contains an API key with prefix "apix_sb_"
|
And the response contains an API key with prefix "apix_sb_"
|
||||||
And the response contains tier "FREE"
|
And the response contains tier "FREE"
|
||||||
And the response contains a non-null expiresAt
|
And the response contains a non-null expiresAt
|
||||||
And the response contains _links.self ending with "/sandbox/peter-demo"
|
And the response contains _links.self
|
||||||
And the response contains _links.services
|
And the response contains _links.services
|
||||||
|
|
||||||
Scenario: Registration fails when name contains uppercase letters
|
Scenario: Registration fails when name contains uppercase letters
|
||||||
@@ -26,7 +26,7 @@ Feature: Sandbox registration
|
|||||||
When an agent registers a sandbox named "valid-name" with email "not-an-email"
|
When an agent registers a sandbox named "valid-name" with email "not-an-email"
|
||||||
Then the response code is 400
|
Then the response code is 400
|
||||||
|
|
||||||
Scenario: Duplicate name is rejected with 409
|
Scenario: Duplicate name is allowed since names are display-only labels
|
||||||
Given a sandbox named "duplicate-test" exists
|
Given a sandbox named "duplicate-test" exists
|
||||||
When an agent registers a sandbox named "duplicate-test" with email "other@example.com"
|
When an agent registers a sandbox named "duplicate-test" with email "other@example.com"
|
||||||
Then the response code is 409
|
Then the response code is 201
|
||||||
|
|||||||
+17
-1
@@ -6,7 +6,7 @@ Feature: Sandbox service isolation from production
|
|||||||
|
|
||||||
Scenario: Service registered in sandbox does not appear in production list
|
Scenario: Service registered in sandbox does not appear in production list
|
||||||
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
|
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
|
||||||
When GET /services is called without authentication
|
When GET /services is called with capability "test.cap" without authentication
|
||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And "https://sandbox.example.com" is not in the endpoint list
|
And "https://sandbox.example.com" is not in the endpoint list
|
||||||
|
|
||||||
@@ -43,3 +43,19 @@ Feature: Sandbox service isolation from production
|
|||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And "https://ext-eu.example.com" is in the endpoint list
|
And "https://ext-eu.example.com" is in the endpoint list
|
||||||
And "https://ext-us.example.com" is not in the endpoint list
|
And "https://ext-us.example.com" is not in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with sandbox key scopes results to sandbox only
|
||||||
|
Given a sandbox service with endpoint "https://keyed-sb.example.com" and capability "keyed.cap" is registered in "isolation-test"
|
||||||
|
When GET /services is called with capability "keyed.cap" and the sandbox key for "isolation-test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://keyed-sb.example.com" is in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with sandbox key does not return production services
|
||||||
|
When GET /services is called with capability "device.telemetry" and the sandbox key for "isolation-test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod.example.com" is not in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with invalid sandbox key falls back to production
|
||||||
|
When GET /services is called with capability "device.telemetry" and API key "apix_sb_000invalid"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod.example.com" is in the endpoint list
|
||||||
|
|||||||
+5
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
|
|||||||
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
|
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
|
||||||
Map<String, String> out = new TreeMap<>();
|
Map<String, String> out = new TreeMap<>();
|
||||||
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
|
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
|
||||||
|
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
|
||||||
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
|
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
|
||||||
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
|
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
|
||||||
return render(out);
|
return render(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String normaliseIotReady(String v) {
|
||||||
|
return "true".equalsIgnoreCase(v) ? "true" : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Individual value normalisers — package-private for unit tests ──────────
|
// ── Individual value normalisers — package-private for unit tests ──────────
|
||||||
|
|
||||||
String normaliseCapability(String v) {
|
String normaliseCapability(String v) {
|
||||||
|
|||||||
+14
-4
@@ -11,12 +11,15 @@ import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
|||||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||||
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
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.RegistryService;
|
||||||
|
import org.botstandards.apix.registry.service.SandboxService;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||||
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,6 +33,9 @@ public class ServiceResource {
|
|||||||
@Inject
|
@Inject
|
||||||
RegistryService registryService;
|
RegistryService registryService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SandboxService sandboxService;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
MeterRegistry meters;
|
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")
|
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
||||||
@QueryParam("stage") String stage,
|
@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")
|
@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()) {
|
if (capability == null || capability.isBlank()) {
|
||||||
throw new BadRequestException("capability query parameter is required");
|
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)
|
.map(ServiceResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -143,14 +152,15 @@ public class ServiceResource {
|
|||||||
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
|
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
|
||||||
)
|
)
|
||||||
public Response getReplacements(@PathParam("id") UUID id,
|
public Response getReplacements(@PathParam("id") UUID id,
|
||||||
|
@Context UriInfo uriInfo,
|
||||||
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
|
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
|
||||||
@QueryParam("minOLevel") String minOLevel,
|
@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).")
|
@Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
|
||||||
@QueryParam("deviceClass") String deviceClass,
|
@QueryParam("deviceClass") String deviceClass,
|
||||||
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
|
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
|
||||||
@QueryParam("protocol") String protocol) {
|
@QueryParam("protocol") String protocol) {
|
||||||
|
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
|
||||||
|
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
|
||||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
||||||
return Response.ok(body)
|
return Response.ok(body)
|
||||||
.header("Cache-Control", "public, max-age=60")
|
.header("Cache-Control", "public, max-age=60")
|
||||||
|
|||||||
+32
-24
@@ -127,18 +127,22 @@ public class RegistryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@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 targetStage = stage != null
|
||||||
? ServiceStage.valueOf(stage.toUpperCase())
|
? ServiceStage.valueOf(stage.toUpperCase())
|
||||||
: ServiceStage.PRODUCTION;
|
: sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
|
||||||
|
|
||||||
List<String[]> props = parsePropertyFilters(properties);
|
List<String[]> props = parsePropertyFilters(properties);
|
||||||
|
String scopeClause = sandboxId != null
|
||||||
|
? "AND s.sandbox_id = :sid"
|
||||||
|
: "AND s.sandbox_id IS NULL";
|
||||||
StringBuilder sql = new StringBuilder(
|
StringBuilder sql = new StringBuilder(
|
||||||
"SELECT s.* FROM services s " +
|
"SELECT s.* FROM services s " +
|
||||||
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
||||||
"AND s.registry_status = 'ACTIVE' " +
|
"AND s.registry_status = 'ACTIVE' " +
|
||||||
"AND s.service_stage = :stage " +
|
"AND s.service_stage = :stage " +
|
||||||
"AND s.sandbox_id IS NULL");
|
scopeClause);
|
||||||
for (int i = 0; i < props.size(); i++) {
|
for (int i = 0; i < props.size(); i++) {
|
||||||
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
|
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
|
||||||
.append(" = :propValue").append(i);
|
.append(" = :propValue").append(i);
|
||||||
@@ -147,6 +151,7 @@ public class RegistryService {
|
|||||||
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
||||||
.setParameter("cap", capability)
|
.setParameter("cap", capability)
|
||||||
.setParameter("stage", targetStage.name());
|
.setParameter("stage", targetStage.name());
|
||||||
|
if (sandboxId != null) q.setParameter("sid", sandboxId);
|
||||||
for (int i = 0; i < props.size(); i++) {
|
for (int i = 0; i < props.size(); i++) {
|
||||||
q.setParameter("propKey" + i, props.get(i)[0]);
|
q.setParameter("propKey" + i, props.get(i)[0]);
|
||||||
q.setParameter("propValue" + i, props.get(i)[1]);
|
q.setParameter("propValue" + i, props.get(i)[1]);
|
||||||
@@ -167,7 +172,7 @@ public class RegistryService {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
||||||
Boolean iotReady, String deviceClass, String protocol) {
|
boolean iotReady, String deviceClass, String protocol) {
|
||||||
ServiceEntity deprecated = requireById(deprecatedId);
|
ServiceEntity deprecated = requireById(deprecatedId);
|
||||||
|
|
||||||
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
// 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());
|
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 " +
|
"SELECT s.* FROM services s " +
|
||||||
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
|
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
|
||||||
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
|
if (needsIotJoin) {
|
||||||
ServiceEntity.class)
|
sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
|
||||||
if (deviceClass != null && !deviceClass.isBlank()) {
|
if (deviceClass != null && !deviceClass.isBlank()) {
|
||||||
stream = stream.filter(c -> c.iotProfile != null
|
sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
|
||||||
&& c.iotProfile.deviceClasses != null
|
|
||||||
&& c.iotProfile.deviceClasses.contains(deviceClass));
|
|
||||||
}
|
}
|
||||||
if (protocol != null && !protocol.isBlank()) {
|
if (protocol != null && !protocol.isBlank()) {
|
||||||
stream = stream.filter(c -> c.iotProfile != null
|
sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)");
|
||||||
&& c.iotProfile.protocols != null
|
|
||||||
&& c.iotProfile.protocols.contains(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(
|
return new ReplacementsResponse(
|
||||||
deprecated.locked,
|
deprecated.locked,
|
||||||
deprecated.sunsetAt,
|
deprecated.sunsetAt,
|
||||||
stream
|
candidates.stream()
|
||||||
|
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
|
||||||
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
||||||
.map(c -> new ReplacementsResponse.Candidate(
|
.map(c -> new ReplacementsResponse.Candidate(
|
||||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
||||||
|
|||||||
Reference in New Issue
Block a user