Эх сурвалжийг харах

test(ai-terminal): expand coverage — heartbeat, scene admission gate, HMAC edge cases

DeviceRegistryFacadeImplTest (18 tests, up from 2):
- Register: 5 tests — new device pending, re-register existing
  activated/online/rejected/pending devices
- Heartbeat: 6 tests — unknown/rejected/pending/activated/online/offline
  (pending devices heartbeat accepted but status unchanged — P0 fix covered)
- Scene: 7 tests — unknown→null, pending→null, rejected→null,
  activated/online with scene binding, no-binding minimal profile,
  JSON array and object capabilities parsing

OpenPlatformAuthInterceptorTest (8 tests, up from 2):
- HMAC: valid signature, timestamp expired rejection, nonce replay
  rejection, invalid signature rejection, unknown access key rejection,
  missing headers passthrough
- Bearer: unknown token rejection, missing Authorization passthrough
WangKang 2 долоо хоног өмнө
parent
commit
bee4b908f7

+ 141 - 1
emoon-openplatform/src/test/java/com/emoon/openplatform/auth/OpenPlatformAuthInterceptorTest.java

@@ -32,8 +32,12 @@ class OpenPlatformAuthInterceptorTest {
                 () -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
     }
 
+    // ═══════════════════════════════════════════════════════════════
+    //  HMAC — happy path
+    // ═══════════════════════════════════════════════════════════════
+
     @Test
-    void hmacSignatureIncludesJsonBody() {
+    void hmacValidSignaturePasses() {
         ISysProjectService projectService = mock(ISysProjectService.class);
         SysProjectDo project = new SysProjectDo();
         project.setId(7);
@@ -58,6 +62,142 @@ class OpenPlatformAuthInterceptorTest {
         assertNotNull(request.getAttribute(OpenPlatformAuthContext.REQUEST_ATTRIBUTE));
     }
 
+    // ═══════════════════════════════════════════════════════════════
+    //  HMAC — edge cases
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    void hmacTimestampExpiredRejected() {
+        ISysProjectService projectService = mock(ISysProjectService.class);
+        SysProjectDo project = new SysProjectDo();
+        project.setId(7);
+        project.setPrivateKey("sk-unit-test");
+        when(projectService.queryByPublicKey("pk-unit-test")).thenReturn(project);
+
+        HmacAuthInterceptor interceptor = new HmacAuthInterceptor(projectService);
+        // Timestamp 10 minutes in the past (>5 min drift)
+        String oldTimestamp = String.valueOf(System.currentTimeMillis() - 10 * 60 * 1000);
+
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+        request.addHeader("X-Emoon-Access-Key", "pk-unit-test");
+        request.addHeader("X-Emoon-Timestamp", oldTimestamp);
+        request.addHeader("X-Emoon-Nonce", "nonce-002");
+        request.addHeader("X-Emoon-Signature", "any");
+
+        assertThrows(ServiceException.class,
+                () -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void hmacNonceReplayRejected() {
+        ISysProjectService projectService = mock(ISysProjectService.class);
+        SysProjectDo project = new SysProjectDo();
+        project.setId(7);
+        project.setPrivateKey("sk-unit-test");
+        when(projectService.queryByPublicKey("pk-unit-test")).thenReturn(project);
+
+        HmacAuthInterceptor interceptor = new HmacAuthInterceptor(projectService);
+        String timestamp = String.valueOf(System.currentTimeMillis());
+        String nonce = "replay-nonce";
+
+        MockHttpServletRequest req1 = requestWithSignature(projectService, timestamp, nonce);
+        // First request — should pass
+        assertDoesNotThrow(() -> interceptor.preHandle(req1, new MockHttpServletResponse(), new Object()));
+
+        MockHttpServletRequest req2 = requestWithSignature(projectService, timestamp, nonce);
+        // Same timestamp+nonce → replay
+        assertThrows(ServiceException.class,
+                () -> interceptor.preHandle(req2, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void hmacInvalidSignatureRejected() {
+        ISysProjectService projectService = mock(ISysProjectService.class);
+        SysProjectDo project = new SysProjectDo();
+        project.setId(7);
+        project.setPrivateKey("sk-unit-test");
+        when(projectService.queryByPublicKey("pk-unit-test")).thenReturn(project);
+
+        HmacAuthInterceptor interceptor = new HmacAuthInterceptor(projectService);
+        String timestamp = String.valueOf(System.currentTimeMillis());
+
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+        request.addHeader("X-Emoon-Access-Key", "pk-unit-test");
+        request.addHeader("X-Emoon-Timestamp", timestamp);
+        request.addHeader("X-Emoon-Nonce", "nonce-003");
+        request.addHeader("X-Emoon-Signature", "wrong-signature-value");
+
+        assertThrows(ServiceException.class,
+                () -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void hmacUnknownAccessKeyRejected() {
+        ISysProjectService projectService = mock(ISysProjectService.class);
+        when(projectService.queryByPublicKey("unknown-key")).thenReturn(null);
+
+        HmacAuthInterceptor interceptor = new HmacAuthInterceptor(projectService);
+        String timestamp = String.valueOf(System.currentTimeMillis());
+
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+        request.addHeader("X-Emoon-Access-Key", "unknown-key");
+        request.addHeader("X-Emoon-Timestamp", timestamp);
+        request.addHeader("X-Emoon-Nonce", "nonce-004");
+        request.addHeader("X-Emoon-Signature", "any");
+
+        assertThrows(ServiceException.class,
+                () -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    @Test
+    void hmacMissingHeadersPassthrough() {
+        // When no HMAC headers present, interceptor should pass through
+        // (let BearerTokenInterceptor or legacy auth try next)
+        ISysProjectService projectService = mock(ISysProjectService.class);
+        HmacAuthInterceptor interceptor = new HmacAuthInterceptor(projectService);
+
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+
+        assertDoesNotThrow(() -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  Bearer — edge cases
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    void bearerMissingAuthorizationPassthrough() {
+        BearerTokenInterceptor interceptor = new BearerTokenInterceptor(new OpenPlatformAuthProperties());
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+
+        // No Authorization header → should pass through
+        assertDoesNotThrow(() -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  Helpers
+    // ═══════════════════════════════════════════════════════════════
+
+    private static MockHttpServletRequest requestWithSignature(
+            ISysProjectService projectService, String timestamp, String nonce) {
+        SysProjectDo project = new SysProjectDo();
+        project.setId(7);
+        project.setPrivateKey("sk-unit-test");
+        when(projectService.queryByPublicKey("pk-unit-test")).thenReturn(project);
+
+        String body = "{\"test\":true}";
+        String canonical = "POST\n/api/v1/devices/register\n" + timestamp + "\n" + nonce + "\n" + sha256Hex(body);
+
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/devices/register");
+        request.setContentType("application/json");
+        request.setContent(body.getBytes(StandardCharsets.UTF_8));
+        request.addHeader("X-Emoon-Access-Key", "pk-unit-test");
+        request.addHeader("X-Emoon-Timestamp", timestamp);
+        request.addHeader("X-Emoon-Nonce", nonce);
+        request.addHeader("X-Emoon-Signature", hmacSha256Base64("sk-unit-test", canonical));
+        return request;
+    }
+
     private static String hmacSha256Base64(String secret, String data) {
         try {
             Mac mac = Mac.getInstance("HmacSHA256");

+ 336 - 35
emoon-openplatform/src/test/java/com/emoon/openplatform/device/DeviceRegistryFacadeImplTest.java

@@ -8,51 +8,352 @@ import com.emoon.ai.device.domain.DeviceRegistryDO;
 import com.emoon.ai.device.domain.DeviceSceneBindingDO;
 import com.emoon.ai.device.mapper.DeviceRegistryMapper;
 import com.emoon.ai.device.mapper.DeviceSceneBindingMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
+import java.util.List;
 import java.util.Map;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
 
+@DisplayName("DeviceRegistryFacadeImpl — main flow coverage")
 class DeviceRegistryFacadeImplTest {
 
-    private final DeviceRegistryMapper deviceMapper = mock(DeviceRegistryMapper.class);
-    private final DeviceSceneBindingMapper sceneMapper = mock(DeviceSceneBindingMapper.class);
-    private final DeviceRegistryFacadeImpl facade = new DeviceRegistryFacadeImpl(deviceMapper, sceneMapper);
+    private DeviceRegistryMapper deviceMapper;
+    private DeviceSceneBindingMapper sceneMapper;
+    private DeviceRegistryFacadeImpl facade;
 
-    @Test
-    void unknownDeviceRegistersAsPendingEvenWhenTypeIsKnown() {
-        when(deviceMapper.selectByDeviceId("NEW-KIOSK")).thenReturn(null);
+    @BeforeEach
+    void setUp() {
+        deviceMapper = mock(DeviceRegistryMapper.class);
+        sceneMapper = mock(DeviceSceneBindingMapper.class);
+        facade = new DeviceRegistryFacadeImpl(deviceMapper, sceneMapper);
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  REGISTER
+    // ═══════════════════════════════════════════════════════════════
+
+    @Nested
+    @DisplayName("register()")
+    class Register {
+
+        @Test
+        @DisplayName("unknown device → pending with B level")
+        void unknownDeviceRegistersAsPending() {
+            when(deviceMapper.selectByDeviceId("NEW-KIOSK")).thenReturn(null);
+
+            DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
+                    "1", "H001", "NEW-KIOSK", "self_service_kiosk",
+                    "vendor", "model", "0.1.0", Map.of("touch", true), Map.of()));
+
+            assertThat(result.activateStatus()).isEqualTo("pending");
+            assertThat(result.admissionLevel()).isEqualTo("B");
+        }
+
+        @Test
+        @DisplayName("existing activated device → returns activated")
+        void existingActivatedReturnsActivated() {
+            DeviceRegistryDO existing = deviceStub("KIOSK-001", "activated", "A");
+            when(deviceMapper.selectByDeviceId("KIOSK-001")).thenReturn(existing);
+
+            DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
+                    "1", "H001", "KIOSK-001", "self_service_kiosk",
+                    null, null, "0.2.0", Map.of(), Map.of()));
+
+            assertThat(result.activateStatus()).isEqualTo("activated");
+            assertThat(result.admissionLevel()).isEqualTo("A");
+            assertThat(result.reason()).isNull();
+            verify(deviceMapper, never()).insert(any(DeviceRegistryDO.class));
+        }
 
-        DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
-                "1", "H001", "NEW-KIOSK", "self_service_kiosk",
-                "vendor", "model", "0.1.0", Map.of("touch", true), Map.of()));
+        @Test
+        @DisplayName("existing online device → returns activated")
+        void existingOnlineReturnsActivated() {
+            DeviceRegistryDO existing = deviceStub("KIOSK-002", "online", "A");
+            when(deviceMapper.selectByDeviceId("KIOSK-002")).thenReturn(existing);
 
-        assertEquals("pending", result.activateStatus());
-        assertEquals("B", result.admissionLevel());
+            DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
+                    "1", "H001", "KIOSK-002", "self_service_kiosk",
+                    null, null, "0.2.0", Map.of(), Map.of()));
+
+            assertThat(result.activateStatus()).isEqualTo("activated");
+        }
+
+        @Test
+        @DisplayName("existing rejected device → returns rejected with reason")
+        void existingRejectedReturnsRejected() {
+            DeviceRegistryDO existing = deviceStub("KIOSK-003", "rejected", "C");
+            when(deviceMapper.selectByDeviceId("KIOSK-003")).thenReturn(existing);
+
+            DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
+                    "1", "H001", "KIOSK-003", "self_service_kiosk",
+                    null, null, "0.1.0", Map.of(), Map.of()));
+
+            assertThat(result.activateStatus()).isEqualTo("rejected");
+            assertThat(result.admissionLevel()).isEqualTo("C");
+            assertThat(result.reason()).contains("拒绝");
+        }
+
+        @Test
+        @DisplayName("existing pending device → returns pending with reason")
+        void existingPendingReturnsPending() {
+            DeviceRegistryDO existing = deviceStub("KIOSK-004", "pending", "B");
+            when(deviceMapper.selectByDeviceId("KIOSK-004")).thenReturn(existing);
+
+            DeviceRegisterResult result = facade.register(new DeviceRegisterCommand(
+                    "1", "H001", "KIOSK-004", "self_service_kiosk",
+                    null, null, "0.1.0", Map.of(), Map.of()));
+
+            assertThat(result.activateStatus()).isEqualTo("pending");
+            assertThat(result.admissionLevel()).isEqualTo("B");
+            assertThat(result.reason()).contains("审核");
+        }
     }
 
-    @Test
-    void sceneSupportsCapabilitiesStoredAsJsonArray() {
-        DeviceRegistryDO device = new DeviceRegistryDO();
-        device.setDeviceId("EMOON-KIOSK-001");
-        device.setDeviceType("self_service_kiosk");
-        device.setCapabilitiesJson("[\"touch\",\"camera\"]");
-        when(deviceMapper.selectByDeviceIdAndProjectId("EMOON-KIOSK-001", "1")).thenReturn(device);
-
-        DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
-        binding.setSceneCode("outpatient_kiosk");
-        binding.setUiTemplate("kiosk_home_v1");
-        binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\"]}");
-        binding.setCardScopesJson("[\"department-selection\"]");
-        when(sceneMapper.selectByDeviceId("EMOON-KIOSK-001")).thenReturn(binding);
-
-        SceneProfileResult result = facade.scene("1", "EMOON-KIOSK-001");
-
-        assertTrue(result.capabilities().contains("touch"));
-        assertTrue(result.capabilities().contains("camera"));
+    // ═══════════════════════════════════════════════════════════════
+    //  HEARTBEAT
+    // ═══════════════════════════════════════════════════════════════
+
+    @Nested
+    @DisplayName("heartbeat()")
+    class Heartbeat {
+
+        @Test
+        @DisplayName("unknown device → returns false")
+        void unknownDeviceReturnsFalse() {
+            when(deviceMapper.selectByDeviceIdAndProjectId("UNKNOWN", "1")).thenReturn(null);
+
+            boolean ok = facade.heartbeat("1", "UNKNOWN", "0.1.0", null, "online", null);
+
+            assertThat(ok).isFalse();
+            verify(deviceMapper, never()).updateById(any(DeviceRegistryDO.class));
+        }
+
+        @Test
+        @DisplayName("rejected device → returns false, no update")
+        void rejectedDeviceReturnsFalse() {
+            DeviceRegistryDO device = deviceStub("REJ-001", "rejected", "D");
+            when(deviceMapper.selectByDeviceIdAndProjectId("REJ-001", "1")).thenReturn(device);
+
+            boolean ok = facade.heartbeat("1", "REJ-001", "0.1.0", null, "online", null);
+
+            assertThat(ok).isFalse();
+            verify(deviceMapper, never()).updateById(any(DeviceRegistryDO.class));
+        }
+
+        @Test
+        @DisplayName("pending device → heartbeat returns false, status unchanged")
+        void pendingDeviceHeartbeatReturnsFalse() {
+            DeviceRegistryDO device = new DeviceRegistryDO();
+            device.setDeviceId("PEND-HB");
+            device.setProjectId("1");
+            device.setStatus("pending");
+            device.setAdmissionLevel("B");
+            device.setClientVersion("0.1.0");
+            when(deviceMapper.selectByDeviceIdAndProjectId(eq("PEND-HB"), eq("1"))).thenReturn(device);
+
+            boolean ok = facade.heartbeat("1", "PEND-HB", "0.1.0", null, "online", null);
+
+            assertThat(ok).isFalse();
+        }
+
+        @Test
+        @DisplayName("activated device → updates status and timestamps, returns true")
+        void activatedDeviceUpdatesStatus() {
+            DeviceRegistryDO device = deviceStub("ACT-001", "activated", "A");
+            when(deviceMapper.selectByDeviceIdAndProjectId("ACT-001", "1")).thenReturn(device);
+
+            boolean ok = facade.heartbeat("1", "ACT-001", "0.2.0", "10.0.0.1", "online", "outpatient_kiosk");
+
+            assertThat(ok).isTrue();
+            verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d ->
+                    "online".equals(d.getStatus())
+                    && "0.2.0".equals(d.getClientVersion())
+                    && d.getLastHeartbeat() != null
+            ));
+        }
+
+        @Test
+        @DisplayName("online device → preserves online status, returns true")
+        void onlineDevicePreservesStatus() {
+            DeviceRegistryDO device = deviceStub("ONL-001", "online", "A");
+            when(deviceMapper.selectByDeviceIdAndProjectId("ONL-001", "1")).thenReturn(device);
+
+            boolean ok = facade.heartbeat("1", "ONL-001", "0.3.0", null, null, null);
+
+            assertThat(ok).isTrue();
+            // When client doesn't send status, defaults to "online" — fine for already-active device
+            verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d -> "online".equals(d.getStatus())));
+        }
+
+        @Test
+        @DisplayName("activated device with offline status → status changes to offline")
+        void activatedDeviceGoesOffline() {
+            DeviceRegistryDO device = deviceStub("ACT-002", "activated", "A");
+            when(deviceMapper.selectByDeviceIdAndProjectId("ACT-002", "1")).thenReturn(device);
+
+            boolean ok = facade.heartbeat("1", "ACT-002", "0.1.0", null, "offline", null);
+
+            assertThat(ok).isTrue();
+            verify(deviceMapper).updateById(org.mockito.Mockito.<DeviceRegistryDO>argThat(d -> "offline".equals(d.getStatus())));
+        }
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  SCENE
+    // ═══════════════════════════════════════════════════════════════
+
+    @Nested
+    @DisplayName("scene()")
+    class Scene {
+
+        @Test
+        @DisplayName("unknown device → returns null")
+        void unknownDeviceReturnsNull() {
+            when(deviceMapper.selectByDeviceIdAndProjectId("UNKNOWN", "1")).thenReturn(null);
+
+            SceneProfileResult result = facade.scene("1", "UNKNOWN");
+
+            assertThat(result).isNull();
+        }
+
+        @Test
+        @DisplayName("pending device → returns null (blocked from core flow)")
+        void pendingDeviceReturnsNull() {
+            DeviceRegistryDO device = new DeviceRegistryDO();
+            device.setDeviceId("PEND-SCENE");
+            device.setProjectId("1");
+            device.setStatus("pending");
+            device.setAdmissionLevel("B");
+            when(deviceMapper.selectByDeviceIdAndProjectId(eq("PEND-SCENE"), eq("1"))).thenReturn(device);
+
+            SceneProfileResult result = facade.scene("1", "PEND-SCENE");
+
+            assertThat(result).isNull();
+            verify(sceneMapper, never()).selectByDeviceId(anyString());
+        }
+
+        @Test
+        @DisplayName("rejected device → returns null (blocked from core flow)")
+        void rejectedDeviceReturnsNull() {
+            DeviceRegistryDO device = new DeviceRegistryDO();
+            device.setDeviceId("REJ-SCENE");
+            device.setProjectId("1");
+            device.setStatus("rejected");
+            device.setAdmissionLevel("D");
+            when(deviceMapper.selectByDeviceIdAndProjectId(eq("REJ-SCENE"), eq("1"))).thenReturn(device);
+
+            SceneProfileResult result = facade.scene("1", "REJ-SCENE");
+
+            assertThat(result).isNull();
+            verify(sceneMapper, never()).selectByDeviceId(anyString());
+        }
+
+        @Test
+        @DisplayName("activated device with scene → returns full profile")
+        void activatedDeviceWithSceneReturnsFullProfile() {
+            DeviceRegistryDO device = deviceStub("KIOSK-001", "activated", "A");
+            device.setDeviceType("self_service_kiosk");
+            device.setCapabilitiesJson("[\"touch\",\"camera\",\"id_card_ocr\"]");
+            when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-001", "1")).thenReturn(device);
+
+            DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
+            binding.setSceneCode("outpatient_kiosk");
+            binding.setUiTemplate("kiosk_home_v1");
+            binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\",\"opd-registration-agent\"]}");
+            binding.setCardScopesJson("[\"department-selection\",\"doctor-selection\"]");
+            when(sceneMapper.selectByDeviceId("KIOSK-001")).thenReturn(binding);
+
+            SceneProfileResult result = facade.scene("1", "KIOSK-001");
+
+            assertThat(result).isNotNull();
+            assertThat(result.deviceId()).isEqualTo("KIOSK-001");
+            assertThat(result.deviceType()).isEqualTo("self_service_kiosk");
+            assertThat(result.sceneCode()).isEqualTo("outpatient_kiosk");
+            assertThat(result.homeTemplate()).isEqualTo("kiosk_home_v1");
+            assertThat(result.defaultAgent()).isEqualTo("opd-guide-agent");
+            assertThat(result.allowedAgents()).containsExactly("opd-guide-agent", "opd-registration-agent");
+            assertThat(result.allowedCards()).containsExactly("department-selection", "doctor-selection");
+            assertThat(result.capabilities()).contains("touch", "camera", "id_card_ocr");
+        }
+
+        @Test
+        @DisplayName("online device with scene → returns full profile")
+        void onlineDeviceWithSceneReturnsFullProfile() {
+            DeviceRegistryDO device = deviceStub("KIOSK-002", "online", "A");
+            device.setDeviceType("robot");
+            device.setCapabilitiesJson("[\"voice\",\"navigation\"]");
+            when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-002", "1")).thenReturn(device);
+
+            DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
+            binding.setSceneCode("outpatient_robot");
+            binding.setUiTemplate("robot_home_v1");
+            binding.setAgentBindingsJson("{\"defaultAgent\":\"opd-guide-agent\",\"allowedAgents\":[\"opd-guide-agent\"]}");
+            binding.setCardScopesJson("[\"route-card\"]");
+            when(sceneMapper.selectByDeviceId("KIOSK-002")).thenReturn(binding);
+
+            SceneProfileResult result = facade.scene("1", "KIOSK-002");
+
+            assertThat(result).isNotNull();
+            assertThat(result.deviceType()).isEqualTo("robot");
+            assertThat(result.allowedAgents()).containsExactly("opd-guide-agent");
+        }
+
+        @Test
+        @DisplayName("activated device without scene binding → returns minimal profile")
+        void activatedDeviceWithoutSceneBindingReturnsMinimalProfile() {
+            DeviceRegistryDO device = deviceStub("KIOSK-003", "activated", "A");
+            device.setDeviceType("self_service_kiosk");
+            device.setCapabilitiesJson(null);
+            when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-003", "1")).thenReturn(device);
+            when(sceneMapper.selectByDeviceId("KIOSK-003")).thenReturn(null);
+
+            SceneProfileResult result = facade.scene("1", "KIOSK-003");
+
+            assertThat(result).isNotNull();
+            assertThat(result.deviceId()).isEqualTo("KIOSK-003");
+            assertThat(result.sceneCode()).isNull();
+            assertThat(result.homeTemplate()).isEqualTo("default");
+        }
+
+        @Test
+        @DisplayName("JSON object capabilities → parsed as key=true entries")
+        void jsonObjectCapabilitiesParsedCorrectly() {
+            DeviceRegistryDO device = deviceStub("KIOSK-004", "activated", "A");
+            device.setCapabilitiesJson("{\"touch\":true,\"camera\":true,\"printer\":false}");
+            when(deviceMapper.selectByDeviceIdAndProjectId("KIOSK-004", "1")).thenReturn(device);
+
+            DeviceSceneBindingDO binding = new DeviceSceneBindingDO();
+            binding.setAgentBindingsJson("{\"defaultAgent\":\"d\",\"allowedAgents\":[]}");
+            binding.setCardScopesJson("[]");
+            when(sceneMapper.selectByDeviceId("KIOSK-004")).thenReturn(binding);
+
+            SceneProfileResult result = facade.scene("1", "KIOSK-004");
+
+            assertThat(result.capabilities()).contains("touch", "camera");
+            assertThat(result.capabilities()).doesNotContain("printer"); // false
+        }
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  Helpers
+    // ═══════════════════════════════════════════════════════════════
+
+    private DeviceRegistryDO deviceStub(String deviceId, String status, String admissionLevel) {
+        DeviceRegistryDO d = new DeviceRegistryDO();
+        d.setId((long) deviceId.hashCode());
+        d.setDeviceId(deviceId);
+        d.setProjectId("1");
+        d.setHospitalId("H001");
+        d.setDeviceType("self_service_kiosk");
+        d.setStatus(status);
+        d.setAdmissionLevel(admissionLevel);
+        d.setClientVersion("0.1.0");
+        return d;
     }
-}
+}