diff --git a/services/base-jdbc/pom.xml b/services/base-jdbc/pom.xml index 8d871a3e5c..924436c80a 100644 --- a/services/base-jdbc/pom.xml +++ b/services/base-jdbc/pom.xml @@ -112,7 +112,11 @@ truth test - + + org.eclipse.hono + core-test-utils + test + diff --git a/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStore.java b/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStore.java index 174f38a382..6b7dad4bce 100644 --- a/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStore.java +++ b/services/base-jdbc/src/main/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStore.java @@ -103,27 +103,25 @@ public Future> readDevice(final DeviceKey key, final .withTag(TracingHelper.TAG_DEVICE_ID, key.getDeviceId()) .start(); - return this.client.getConnection().compose(connection -> { - return readDevice(connection, key, span) - - .>flatMap(r -> { - final var entries = r.getRows(true); - switch (entries.size()) { - case 0: - return Future.succeededFuture(Optional.empty()); - case 1: - final var entry = entries.get(0); - final var device = Json.decodeValue(entry.getString("data"), Device.class); - final var version = Optional.ofNullable(entry.getString("version")); - return Future.succeededFuture(Optional.of(new DeviceReadResult(device, version))); - default: + return this.client.getConnection().compose(connection -> readDevice(connection, key, span) + .> flatMap(r -> { + final var entries = r.getRows(true); + switch (entries.size()) { + case 0: + return Future.succeededFuture(Optional.empty()); + case 1: + final var entry = entries.get(0); + final var deviceJson = entry.getString("data") != null ? entry.getString("data") : "{}"; + final var device = Json.decodeValue(deviceJson, Device.class); + final var version = Optional.ofNullable(entry.getString("version")); + return Future.succeededFuture(Optional.of(new DeviceReadResult(device, version))); + default: return Future.failedFuture(new IllegalStateException("Found multiple entries for a single device")); - } - }) + } + }) - .onComplete(x -> connection.close()) - .onComplete(x -> span.finish()); - }); + .onComplete(x -> connection.close()) + .onComplete(x -> span.finish())); } diff --git a/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/StatementTest.java b/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/StatementTest.java index e2ffc6fce2..5df39856f5 100644 --- a/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/StatementTest.java +++ b/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/StatementTest.java @@ -49,9 +49,7 @@ public void testValidateFields() { @Test public void testValidateMissingField() { final Statement statement = Statement.statement("SELECT foo FROM bar WHERE baz=:baz"); - assertThrows(IllegalStateException.class, () -> { - statement.validateParameters("bar"); - }); + assertThrows(IllegalStateException.class, () -> statement.validateParameters("bar")); } /** @@ -68,14 +66,15 @@ public void testValidateAdditionalField() { */ @Test public void testValidateAdditionalField2() { - final Statement statement = Statement.statement("UPDATE devices\n" + - "SET\n" + - " data=:data::jsonb,\n" + - " version=:next_version\n" + - "WHERE\n" + - " tenant_id=:tenant_id\n" + - "AND\n" + - " device_id=:device_id"); + final Statement statement = Statement.statement(""" + UPDATE devices + SET + data=:data::jsonb, + version=:next_version + WHERE + tenant_id=:tenant_id + AND + device_id=:device_id"""); statement.validateParameters("data", "device_id", "next_version", "tenant_id"); } @@ -102,7 +101,7 @@ public void testPlainYamlStillWorksForStatementConfig() { @Test public void testObjectCreationRejectedMapValue(@TempDir final Path tempDir) { final Path markerFile = tempDir.resolve("testObjectCreationRejectedMapValue.marker"); - final String yaml = "read: !!java.io.FileOutputStream [" + markerFile.toAbsolutePath().toString() + "]"; + final String yaml = "read: !!java.io.FileOutputStream [" + markerFile.toAbsolutePath() + "]"; assertNoMarkerFile(markerFile, yaml); } @@ -115,7 +114,7 @@ public void testObjectCreationRejectedMapValue(@TempDir final Path tempDir) { @Test public void testObjectCreationRejectedPlainValue(@TempDir final Path tempDir) { final Path markerFile = tempDir.resolve("testObjectCreationRejectedPlainValue.marker"); - final String yaml = "!!java.io.FileOutputStream [" + markerFile.toAbsolutePath().toString() + "]"; + final String yaml = "!!java.io.FileOutputStream [" + markerFile.toAbsolutePath() + "]"; assertNoMarkerFile(markerFile, yaml); } diff --git a/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStoreTest.java b/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStoreTest.java new file mode 100644 index 0000000000..3c8912fb7d --- /dev/null +++ b/services/base-jdbc/src/test/java/org/eclipse/hono/service/base/jdbc/store/device/TableAdapterStoreTest.java @@ -0,0 +1,225 @@ +/******************************************************************************* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.hono.service.base.jdbc.store.device; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.hono.deviceregistry.service.device.DeviceKey; +import org.eclipse.hono.service.base.jdbc.store.StatementConfiguration; +import org.eclipse.hono.service.management.device.Device; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import io.opentracing.Span; +import io.opentracing.SpanContext; +import io.opentracing.Tracer; +import io.opentracing.tag.StringTag; +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; +import io.vertx.jdbcclient.JDBCPool; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowIterator; +import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.Tuple; + +/** + * Tests the handling of device data in the database. + */ +class TableAdapterStoreTest { + + private final Span span = mock(Span.class); + private final SpanContext spanContext = mock(SpanContext.class); + private final io.vertx.sqlclient.SqlConnection sqlConnection = mock(io.vertx.sqlclient.SqlConnection.class); + private final RowSet rowSet = mock(RowSet.class); + private final Row row = mock(Row.class); + private final Tracer.SpanBuilder spanBuilder = mock(Tracer.SpanBuilder.class); + private final Tracer tracer = mock(Tracer.class); + private final JDBCPool client = mock(JDBCPool.class); + + private String deviceJson; + private TableAdapterStore store; + private final String TENANT_ID = "test-tenant"; + private final String DEVICE_ID = "device-1"; + + /** + * Creates a test device with default values. + */ + private Device createTestDevice() { + return new Device() + .setEnabled(true) + .setVia(Collections.singletonList("group1")); + } + + /** + * Mocks a single row of device data. + * + * @param deviceJson The JSON string of the device data (can be null) + * @param versions The versions the mocked rows contain + */ + private void mockRows(final String deviceJson, final String[] versions) { + // Setup row data + for (String version : versions) { + when(row.size()).thenReturn(2); + when(row.getValue(0)).thenReturn(deviceJson); + when(row.getValue(1)).thenReturn(version); + } + + mockRowSet(versions.length); + + // Mock direct query + doAnswer(invocation -> { + final io.vertx.core.Handler>> handler = invocation.getArgument(0); + handler.handle(Future.succeededFuture(rowSet)); + return null; + }).when(sqlConnection).query(anyString()); + } + + private void mockRowSet(final int numRows) { + final var iterator = mock(RowIterator.class); + when(rowSet.iterator()).thenReturn(iterator); + when(rowSet.columnsNames()).thenReturn(List.of("data", "version")); + when(rowSet.size()).thenReturn(numRows); + doAnswer(invocation -> { + final Consumer consumer = invocation.getArgument(0); + for (int i = 0; i < numRows; i++) { + consumer.accept(row); + } + return null; + }).when(rowSet).forEach(any()); + for (int i = 0; i < numRows; i++) { + when(iterator.hasNext()).thenReturn(true); + } + when(iterator.hasNext()).thenReturn(false); + when(iterator.next()).thenReturn(row); + + // Return rowSet in query: + final var preparedQueryMock = mock(io.vertx.sqlclient.PreparedQuery.class); + when(preparedQueryMock.execute(any(Tuple.class))).thenReturn(Future.succeededFuture(rowSet)); + when(sqlConnection.preparedQuery(anyString())).thenReturn(preparedQueryMock); + } + + @BeforeEach + void setUp() { + // Setup tracing mocks + when(tracer.buildSpan(anyString())).thenReturn(spanBuilder); + when(spanBuilder.addReference(anyString(), any())).thenReturn(spanBuilder); + when(spanBuilder.withTag(anyString(), anyString())).thenReturn(spanBuilder); + when(spanBuilder.withTag(anyString(), any(Number.class))).thenReturn(spanBuilder); + when(spanBuilder.withTag(any(StringTag.class), anyString())).thenReturn(spanBuilder); + when(spanBuilder.ignoreActiveSpan()).thenReturn(spanBuilder); + when(spanBuilder.start()).thenReturn(span); + when(span.context()).thenReturn(spanContext); + + // Setup JDBC client mocks + when(client.getConnection()).thenReturn(Future.succeededFuture(sqlConnection)); + when(sqlConnection.close()).thenReturn(Future.succeededFuture()); + final var statementConfig = StatementConfiguration.empty().overrideWith(getStatementsAsInputStream(), true); + + store = new TableAdapterStore(client, tracer, statementConfig, null); + final var device = createTestDevice(); + deviceJson = JsonObject.mapFrom(device).encode(); + } + + private static @NotNull ByteArrayInputStream getStatementsAsInputStream() { + final Map statements = new HashMap<>(); + statements.put("readRegistration", + "SELECT * FROM devices WHERE tenant_id = :tenant_id AND device_id = :device_id"); + statements.put("findCredentials", + "SELECT * FROM credentials WHERE tenant_id = :tenant_id AND type = :type AND auth_id = :auth_id"); + statements.put("resolveGroups", + "SELECT * FROM device_groups WHERE tenant_id = :tenant_id AND group_id = ANY(:group_ids)"); + + final Yaml yaml = new Yaml(); + final String yamlString = yaml.dump(statements); + return new ByteArrayInputStream(yamlString.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testReadDeviceSuccess() { + // Given + final var version = "v1"; + mockRows(deviceJson, new String[]{version}); + + // When + final var result = store.readDevice(DeviceKey.from(TENANT_ID, DEVICE_ID), spanContext).result(); + + // Then + validateReadDeviceResult(result, version); + } + + @Test + void testReadDeviceSuccess_dataNull() { + // Given + final var version = "v2"; + mockRows(null, new String[]{version}); + + // When + final var result = store.readDevice(DeviceKey.from(TENANT_ID, DEVICE_ID), spanContext).result(); + + // Then + validateReadDeviceResult(result, version); + } + + private void validateReadDeviceResult(final Optional result, final String version) { + assertTrue(result.isPresent()); + final var deviceResult = result.get(); + assertTrue(deviceResult.getDevice().isEnabled()); + assertEquals(version, deviceResult.getResourceVersion().orElse("")); + } + + @Test + void testReadDeviceNotFound() { + // Given + final var key = DeviceKey.from(TENANT_ID, "non-existent-device"); + mockRows(deviceJson, new String[]{}); + + // When + final var future = store.readDevice(key, spanContext); + + // Then + assertTrue(future.succeeded()); + assertTrue(future.result().isEmpty()); + } + + @Test + void testReadDeviceMultipleEntries() { + // Given + final var key = DeviceKey.from(TENANT_ID, "duplicate-device"); + mockRows(deviceJson, new String[]{"v3", "v4"}); + + // When + final var future = store.readDevice(key, spanContext); + + // Then + assertTrue(future.failed()); + assertEquals("Found multiple entries for a single device", future.cause().getMessage()); + } +}