feat(registry): add IoT device connection profile (iot_profiles table)

New optional 1:1 extension to services: providers can declare how IoT
devices connect to their hub (hub_url, protocols, provisioning endpoint,
device classes, firmware compatibility) via PATCH /services/{id}.

- New entity IotProfileEntity + Liquibase changeset 009 (GIN indexes for
  jsonb device_classes and protocols arrays)
- IotProtocol / CredentialFormat enums; IotProfileRequest / IotProfileResponse DTOs
- JsonStringListConverter for jsonb List<String> persistence
- GET /services/{id}/replacements extended with iotProfile per candidate
  and new filter params: iotReady=true, deviceClass=..., protocol=...
- 6 new BDD scenarios (IotProfileCucumberTest) covering profile creation,
  candidate enrichment, iotReady / deviceClass filtering, partial update,
  and missing-field 422 validation
- All 57 tests green (6 new + 27 existing transition + 24 unit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-08 16:12:13 +02:00
parent b2a16a8be7
commit 5156089152
19 changed files with 537 additions and 12 deletions
@@ -0,0 +1,8 @@
package org.botstandards.apix.registry.dto;
public enum CredentialFormat {
JWT,
PSK,
X509,
NONE
}
@@ -0,0 +1,19 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record IotProfileRequest(
String hubUrl,
List<IotProtocol> protocols,
String tlsMinVersion,
String provisioningEndpoint,
CredentialFormat credentialFormat,
String deviceIdField,
List<String> deviceClasses,
String minFirmwareVersion,
String firmwareUpdateUrl,
Integer estimatedMigrationMinutes
) {}
@@ -0,0 +1,36 @@
package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.registry.entity.IotProfileEntity;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record IotProfileResponse(
String hubUrl,
List<String> protocols,
String tlsMinVersion,
String provisioningEndpoint,
String credentialFormat,
String deviceIdField,
List<String> deviceClasses,
String minFirmwareVersion,
String firmwareUpdateUrl,
Integer estimatedMigrationMinutes
) {
public static IotProfileResponse from(IotProfileEntity e) {
if (e == null) return null;
return new IotProfileResponse(
e.hubUrl,
e.protocols,
e.tlsMinVersion,
e.provisioningEndpoint,
e.credentialFormat != null ? e.credentialFormat.name() : null,
e.deviceIdField,
e.deviceClasses,
e.minFirmwareVersion,
e.firmwareUpdateUrl,
e.estimatedMigrationMinutes
);
}
}
@@ -0,0 +1,10 @@
package org.botstandards.apix.registry.dto;
public enum IotProtocol {
MQTT_3_1_1,
MQTT_5_0,
WEBSOCKET,
COAP,
HTTP_1_1,
AMQP
}
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import java.time.Instant;
import java.util.List;
@@ -20,6 +21,7 @@ public record ReplacementsResponse(
String name,
String endpoint,
OLevel oLevel,
ServiceStage serviceStage
ServiceStage serviceStage,
IotProfileResponse iotProfile
) {}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OrgType;
import org.botstandards.apix.common.ServiceStage;
import org.botstandards.apix.registry.dto.IotProfileRequest;
import java.time.Instant;
import java.util.List;
@@ -30,5 +31,6 @@ public record ServicePatchRequest(
Boolean locked,
Instant sunsetAt,
String migrationGuideUrl,
List<UUID> replacesServiceIds
List<UUID> replacesServiceIds,
IotProfileRequest iotProfile
) {}
@@ -3,6 +3,7 @@ package org.botstandards.apix.registry.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import java.time.Instant;
import java.util.List;
@@ -35,7 +36,8 @@ public record ServiceResponse(
String migrationGuideUrl,
List<UUID> replacesServiceIds,
Instant registeredAt,
Instant lastUpdatedAt
Instant lastUpdatedAt,
IotProfileResponse iotProfile
) {
public static ServiceResponse from(ServiceEntity e) {
BsmPayload b = e.bsmPayload;
@@ -65,7 +67,8 @@ public record ServiceResponse(
e.migrationGuideUrl,
b.replacesServiceIds(),
e.registeredAt,
e.lastUpdatedAt
e.lastUpdatedAt,
IotProfileResponse.from(e.iotProfile)
);
}
}
@@ -0,0 +1,64 @@
package org.botstandards.apix.registry.entity;
import jakarta.persistence.*;
import org.botstandards.apix.registry.dto.CredentialFormat;
import org.botstandards.apix.registry.persistence.JsonStringListConverter;
import org.hibernate.annotations.ColumnTransformer;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "iot_profiles")
public class IotProfileEntity {
@Id
@Column(columnDefinition = "uuid")
public UUID id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "service_id", nullable = false, unique = true)
public ServiceEntity service;
@Column(name = "hub_url", nullable = false, length = 512)
public String hubUrl;
@Convert(converter = JsonStringListConverter.class)
@Column(name = "protocols", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public List<String> protocols;
@Column(name = "tls_min_version", length = 10, nullable = false)
public String tlsMinVersion = "1.2";
@Column(name = "provisioning_endpoint", length = 512)
public String provisioningEndpoint;
@Enumerated(EnumType.STRING)
@Column(name = "credential_format", length = 30)
public CredentialFormat credentialFormat;
@Column(name = "device_id_field", length = 100)
public String deviceIdField;
@Convert(converter = JsonStringListConverter.class)
@Column(name = "device_classes", columnDefinition = "jsonb", nullable = false)
@ColumnTransformer(write = "?::jsonb")
public List<String> deviceClasses;
@Column(name = "min_firmware_version", length = 50)
public String minFirmwareVersion;
@Column(name = "firmware_update_url", length = 512)
public String firmwareUpdateUrl;
@Column(name = "estimated_migration_minutes")
public Integer estimatedMigrationMinutes;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
}
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.persistence.BsmPayloadConverter;
import org.hibernate.annotations.ColumnTransformer;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import java.time.Instant;
import java.util.UUID;
@@ -61,4 +63,11 @@ public class ServiceEntity {
@Column(name = "migration_guide_url")
public String migrationGuideUrl;
// EAGER so the profile is available without an explicit @Transactional on read paths.
// iot_profiles rows are sparse — Hibernate uses a LEFT JOIN for em.find()/JPQL,
// and a secondary SELECT per entity for native queries. Both are acceptable at registry scale.
@OneToOne(mappedBy = "service", fetch = FetchType.EAGER, optional = true)
@Fetch(FetchMode.SELECT)
public IotProfileEntity iotProfile;
}
@@ -0,0 +1,34 @@
package org.botstandards.apix.registry.persistence;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.json.JsonMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class JsonStringListConverter implements AttributeConverter<List<String>, String> {
private static final JsonMapper MAPPER = JsonMapper.builder().build();
@Override
public String convertToDatabaseColumn(List<String> attribute) {
if (attribute == null) return null;
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException("Cannot serialize string list to JSON", e);
}
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
try {
return MAPPER.readValue(dbData, new TypeReference<>() {});
} catch (Exception e) {
throw new IllegalStateException("Cannot deserialize JSON to string list", e);
}
}
}
@@ -77,8 +77,11 @@ public class ServiceResource {
@GET
@Path("/{id}/replacements")
public Response getReplacements(@PathParam("id") UUID id,
@QueryParam("minOLevel") String minOLevel) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel);
@QueryParam("minOLevel") String minOLevel,
@QueryParam("iotReady") Boolean iotReady,
@QueryParam("deviceClass") String deviceClass,
@QueryParam("protocol") String protocol) {
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
.build();
@@ -11,12 +11,17 @@ import org.botstandards.apix.common.*;
import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.dto.IotProfileRequest;
import org.botstandards.apix.registry.dto.IotProfileResponse;
import org.botstandards.apix.registry.dto.IotProtocol;
import org.botstandards.apix.registry.entity.IotProfileEntity;
import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
import java.time.Instant;
import java.util.*;
import java.util.stream.Stream;
@ApplicationScoped
public class RegistryService {
@@ -113,6 +118,9 @@ public class RegistryService {
if (req.replacesServiceIds() != null) {
syncReplacements(service.id, req.replacesServiceIds(), service.lastUpdatedAt);
}
if (req.iotProfile() != null) {
upsertIotProfile(service, req.iotProfile(), service.lastUpdatedAt);
}
return service;
}
@@ -135,7 +143,8 @@ public class RegistryService {
}
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr) {
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
Boolean iotReady, String deviceClass, String protocol) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
@@ -153,14 +162,30 @@ public class RegistryService {
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);
}
if (deviceClass != null && !deviceClass.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.deviceClasses != null
&& c.iotProfile.deviceClasses.contains(deviceClass));
}
if (protocol != null && !protocol.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.protocols != null
&& c.iotProfile.protocols.contains(protocol));
}
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
stream
.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))
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
IotProfileResponse.from(c.iotProfile)))
.toList()
);
}
@@ -290,6 +315,35 @@ public class RegistryService {
}
}
private void upsertIotProfile(ServiceEntity service, IotProfileRequest req, Instant now) {
IotProfileEntity p = service.iotProfile;
boolean isNew = (p == null);
if (isNew) {
if (req.hubUrl() == null) throw unprocessable("iotProfile.hubUrl is required");
if (req.protocols() == null || req.protocols().isEmpty()) throw unprocessable("iotProfile.protocols is required");
if (req.deviceClasses() == null || req.deviceClasses().isEmpty()) throw unprocessable("iotProfile.deviceClasses is required");
p = new IotProfileEntity();
p.id = UUID.randomUUID();
p.service = service;
p.createdAt = now;
}
if (req.hubUrl() != null) p.hubUrl = req.hubUrl();
if (req.protocols() != null) p.protocols = req.protocols().stream().map(IotProtocol::name).toList();
if (req.tlsMinVersion() != null) p.tlsMinVersion = req.tlsMinVersion();
if (req.provisioningEndpoint() != null) p.provisioningEndpoint = req.provisioningEndpoint();
if (req.credentialFormat() != null) p.credentialFormat = req.credentialFormat();
if (req.deviceIdField() != null) p.deviceIdField = req.deviceIdField();
if (req.deviceClasses() != null) p.deviceClasses = req.deviceClasses();
if (req.minFirmwareVersion() != null) p.minFirmwareVersion = req.minFirmwareVersion();
if (req.firmwareUpdateUrl() != null) p.firmwareUpdateUrl = req.firmwareUpdateUrl();
if (req.estimatedMigrationMinutes() != null) p.estimatedMigrationMinutes = req.estimatedMigrationMinutes();
p.updatedAt = now;
if (isNew) {
service.iotProfile = p;
em.persist(p);
}
}
private void upsertReplacement(UUID deprecatedId, UUID replacementId, Instant now) {
long count = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM service_replacements " +
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="009" author="apix">
<!-- Optional IoT device-connection profile attached to a registered service.
A service without an iot_profiles row is a generic API service with no
declared device-onboarding support. A service with a row is discoverable
by IoT devices polling for a cloud-sunset replacement target. -->
<createTable tableName="iot_profiles">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- Owning FK — one profile per service, deleted with the service -->
<column name="service_id" type="uuid">
<constraints nullable="false" unique="true"
foreignKeyName="fk_iot_profile_service"
references="services(id)"
deleteCascade="true"/>
</column>
<!-- Device connection -->
<!-- WebSocket / MQTT broker / CoAP endpoint the device connects to -->
<column name="hub_url" type="varchar(512)">
<constraints nullable="false"/>
</column>
<!-- JSONB array of IotProtocol enum values, e.g. ["MQTT_5_0","WEBSOCKET"] -->
<column name="protocols" type="jsonb">
<constraints nullable="false"/>
</column>
<column name="tls_min_version" type="varchar(10)" defaultValue="1.2">
<constraints nullable="false"/>
</column>
<!-- Provisioning — device calls this URL directly; APIX never sees device identity -->
<column name="provisioning_endpoint" type="varchar(512)"/>
<!-- CredentialFormat enum: JWT | PSK | X509 | NONE -->
<column name="credential_format" type="varchar(30)"/>
<!-- Which device field is presented at provisioning (serialNumber, mac, deviceEui) -->
<column name="device_id_field" type="varchar(100)"/>
<!-- Device class targeting — same capability taxonomy as GET /services?capability -->
<!-- JSONB array of class identifiers, e.g. ["device.class.smart-home-hub"] -->
<column name="device_classes" type="jsonb">
<constraints nullable="false"/>
</column>
<!-- Migration aid -->
<column name="min_firmware_version" type="varchar(50)"/>
<column name="firmware_update_url" type="varchar(512)"/>
<!-- Estimated wall-clock minutes to complete the migration (for UX display) -->
<column name="estimated_migration_minutes" type="integer"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Liquibase createIndex does not support PostgreSQL-specific index types;
GIN must be declared via raw SQL for jsonb containment queries. -->
<sql>CREATE INDEX idx_iot_device_classes ON iot_profiles USING gin(device_classes)</sql>
<sql>CREATE INDEX idx_iot_protocols ON iot_profiles USING gin(protocols)</sql>
</changeSet>
</databaseChangeLog>
@@ -13,5 +13,6 @@
<include file="changes/006-sunset-lock.xml" relativeToChangelogFile="true"/>
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,27 @@
package org.botstandards.apix.registry.bdd;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs BDD scenarios for the IoT device connection profile feature.
* Shares step definitions with IotTransitionCucumberTest via the same glue package.
*/
@QuarkusTest
public class IotProfileCucumberTest {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-iot-profile.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
"classpath:features/iot-profile"
);
assertEquals(0, exitCode, "One or more IoT profile Cucumber scenarios failed");
}
}
@@ -783,4 +783,93 @@ public class IotTransitionSteps {
.doesNotContain("user-agent");
}
}
// ── IoT Profile — Given (setup) ──────────────────────────────────────────
@Given("{string} has IoT profile hubUrl {string} protocols {string} deviceClasses {string}")
public void hasIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name))
.then().statusCode(200);
}
// ── IoT Profile — When ───────────────────────────────────────────────────
@When("{string} sets its IoT profile with hubUrl {string} protocols {string} deviceClasses {string}")
public void setsIotProfile(String name, String hubUrl, String protocols, String deviceClasses) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of(
"hubUrl", hubUrl,
"protocols", List.of(protocols),
"deviceClasses", List.of(deviceClasses)
)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} updates its IoT profile hubUrl to {string}")
public void updatesIotProfileHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
currentServiceId = serviceIds.get(name);
}
@When("{string} patches iotProfile with only hubUrl {string}")
public void patchesIotProfileWithOnlyHubUrl(String name, String hubUrl) {
actionPhase = true;
lastResponse = asTemplateOwner()
.body(Map.of("iotProfile", Map.of("hubUrl", hubUrl)))
.patch("/services/" + serviceIds.get(name));
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
public void getReplacementsFilteredByDeviceClass(String deviceClass) {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?deviceClass=" + deviceClass);
}
// ── IoT Profile — Then ───────────────────────────────────────────────────
@Then("^GET /services/\\{openHubId\\} includes an iotProfile$")
public void getOpenHubIncludesIotProfile() {
given().get("/services/" + serviceIds.get("OpenHub"))
.then().statusCode(200)
.body("iotProfile", notNullValue());
}
@Then("the iotProfile.hubUrl is {string}")
public void iotProfileHubUrlIs(String expected) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.hubUrl", equalTo(expected));
}
@Then("the iotProfile.protocols contains {string}")
public void iotProfileProtocolsContains(String protocol) {
given().get("/services/" + currentServiceId)
.then().statusCode(200)
.body("iotProfile.protocols", hasItem(protocol));
}
@Then("the candidate {string} has an iotProfile with hubUrl {string}")
public void candidateHasIotProfileWithHubUrl(String name, String hubUrl) {
lastResponse.then().statusCode(200)
.body("candidates.find { it.name == '" + name + "' }.iotProfile.hubUrl", equalTo(hubUrl));
}
}
@@ -79,7 +79,7 @@ class RegistryServiceTest {
var req = new ServicePatchRequest(
null, null, null, List.of("new.capability"),
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
null, null, null, null, null, null);
assertThat(RegistryService.detectChangeType(ServiceStage.PRODUCTION, null, req))
.isEqualTo(ChangeType.BSM_UPDATED);
}
@@ -188,6 +188,6 @@ class RegistryServiceTest {
private static ServicePatchRequest patch(ServiceStage stage, Boolean locked, Instant sunsetAt, List<UUID> replaces) {
return new ServicePatchRequest(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
stage, locked, sunsetAt, null, replaces);
stage, locked, sunsetAt, null, replaces, null);
}
}
@@ -0,0 +1,55 @@
Feature: IoT device connection profile
As a service provider targeting IoT device portability
I want to declare my device connection profile in the APIX registry
So that migrating IoT devices can discover how to connect to my service
Background:
Given a deprecated service "SmartHub Cloud" with locked set to false
And a registered service "OpenHub" in stage "PRODUCTION" with O-level "IDENTITY_VERIFIED"
And a registered service "CloudBridge" in stage "PRODUCTION" with O-level "LEGAL_ENTITY_VERIFIED"
Scenario: Provider adds an IoT profile to their service
When "OpenHub" sets its IoT profile with hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://hub.openhub.io/v2"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: IoT profile is included in replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io/v2" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements is called
Then the response is HTTP 200
And the candidate "OpenHub" has an iotProfile with hubUrl "wss://hub.openhub.io/v2"
Scenario: iotReady=true excludes candidates without a profile
Given "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?iotReady=true is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "CloudBridge"
Scenario: deviceClass filter narrows replacement candidates
Given "OpenHub" has IoT profile hubUrl "wss://hub.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
And "OpenHub" has declared compatibility with "SmartHub Cloud"
And "CloudBridge" has IoT profile hubUrl "wss://hub.cloudbridge.io" protocols "MQTT_5_0" deviceClasses "device.class.industrial-sensor"
And "CloudBridge" has declared compatibility with "SmartHub Cloud"
When GET /services/{smartHubCloudId}/replacements?deviceClass=device.class.smart-home-hub is called with no authentication header
Then the response is HTTP 200
And the response contains 1 candidate
And the candidate is "OpenHub"
Scenario: IoT profile fields are partially updated on subsequent PATCH
Given "OpenHub" has IoT profile hubUrl "wss://old.openhub.io" protocols "MQTT_5_0" deviceClasses "device.class.smart-home-hub"
When "OpenHub" updates its IoT profile hubUrl to "wss://new.openhub.io"
Then the response is HTTP 200
And GET /services/{openHubId} includes an iotProfile
And the iotProfile.hubUrl is "wss://new.openhub.io"
And the iotProfile.protocols contains "MQTT_5_0"
Scenario: Missing required fields on first IoT profile PATCH returns 422
When "OpenHub" patches iotProfile with only hubUrl "wss://hub.openhub.io"
Then the response is HTTP 422
And the error message contains "iotProfile.protocols is required"