Browse Source

refactor + add tests

pull/15682/head
dpinkevych 2 months ago
parent
commit
bd1952b34c
  1. 94
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  2. 215
      application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java
  3. 10
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html
  4. 13
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts
  5. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

94
application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java

@ -102,6 +102,15 @@ public class DefaultIotHubService implements IotHubService {
private final TbDeviceService tbDeviceService;
private final SolutionService solutionService;
// Field names of the marketplace version JSON payload. Both the install path and the
// install-plan resolver parse the same shape, so the contract lives here in one place.
private static final String FIELD_ID = "id";
private static final String FIELD_ITEM_ID = "itemId";
private static final String FIELD_TYPE = "type";
private static final String FIELD_NAME = "name";
private static final String FIELD_VERSION = "version";
private static final String FIELD_RELATED_ITEMS = "relatedItems";
@Override
public InstallItemVersionResult installItemVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) {
TenantId tenantId = user.getTenantId();
@ -122,10 +131,10 @@ public class DefaultIotHubService implements IotHubService {
private IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception {
TenantId tenantId = user.getTenantId();
JsonNode versionInfo = iotHubRestClient.getVersionInfo(versionId);
String itemType = versionInfo.get("type").asText();
String itemName = versionInfo.get("name").asText();
UUID itemId = UUID.fromString(versionInfo.get("itemId").asText());
String version = versionInfo.get("version").asText();
String itemType = versionInfo.get(FIELD_TYPE).asText();
String itemName = versionInfo.get(FIELD_NAME).asText();
UUID itemId = UUID.fromString(versionInfo.get(FIELD_ITEM_ID).asText());
String version = versionInfo.get(FIELD_VERSION).asText();
log.debug("[{}] Fetched version info: {} (type: {})", tenantId, itemName, itemType);
byte[] fileData = iotHubRestClient.getVersionFileData(versionId);
@ -689,8 +698,8 @@ public class DefaultIotHubService implements IotHubService {
// level deep, so there is no need to walk a related item's own related items.
LinkedHashMap<String, InstallPlanEntry> entries = new LinkedHashMap<>();
String rootItemId = rootVersion.get("itemId").asText();
JsonNode related = rootVersion.get("relatedItems");
String rootItemId = rootVersion.get(FIELD_ITEM_ID).asText();
JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS);
if (related != null && related.isArray()) {
for (JsonNode relatedNode : related) {
String relatedItemId = relatedNode.asText();
@ -729,25 +738,34 @@ public class DefaultIotHubService implements IotHubService {
Set<UUID> alreadyInstalledItemIds,
LinkedHashMap<String, InstallPlanEntry> entries,
boolean root) {
String itemId = versionInfo.get("itemId").asText();
String itemId = optText(versionInfo, FIELD_ITEM_ID);
String versionId = optText(versionInfo, FIELD_ID);
if (itemId == null || versionId == null) {
// The marketplace payload is missing its identifiers. The root must have them to be
// installable; a related item without them is recorded as missing so the rest of the
// plan can still proceed.
if (root) {
throw new IllegalArgumentException("Marketplace version is missing required '"
+ FIELD_ITEM_ID + "'/'" + FIELD_ID + "' fields");
}
String key = itemId != null ? itemId : versionId;
log.warn("Related IoT Hub item is missing required identifiers — recording in plan: {}", versionInfo);
entries.putIfAbsent(key != null ? key : versionInfo.toString(),
missingEntry(itemId, "Item descriptor is incomplete"));
return;
}
if (entries.containsKey(itemId)) {
return;
}
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(itemId);
entry.setVersionId(versionInfo.get("id").asText());
entry.setName(versionInfo.hasNonNull("name") ? versionInfo.get("name").asText() : null);
entry.setType(versionInfo.hasNonNull("type") ? versionInfo.get("type").asText() : null);
entry.setVersion(versionInfo.hasNonNull("version") ? versionInfo.get("version").asText() : null);
entry.setVersionId(versionId);
entry.setName(optText(versionInfo, FIELD_NAME));
entry.setType(optText(versionInfo, FIELD_TYPE));
entry.setVersion(optText(versionInfo, FIELD_VERSION));
entry.setRoot(root);
boolean alreadyInstalled;
try {
alreadyInstalled = alreadyInstalledItemIds.contains(UUID.fromString(itemId));
} catch (IllegalArgumentException ex) {
alreadyInstalled = false;
}
entry.setStatus(alreadyInstalled
entry.setStatus(isAlreadyInstalled(itemId, alreadyInstalledItemIds)
? InstallPlanEntry.Status.ALREADY_INSTALLED
: InstallPlanEntry.Status.WILL_INSTALL);
entries.put(itemId, entry);
@ -761,6 +779,10 @@ public class DefaultIotHubService implements IotHubService {
return entry;
}
private static String optText(JsonNode node, String field) {
return node != null && node.hasNonNull(field) ? node.get(field).asText() : null;
}
@Override
public InstallPlanResult installPlan(SecurityUser user, InstallPlan plan, JsonNode data, HttpServletRequest request) {
TenantId tenantId = user.getTenantId();
@ -768,6 +790,11 @@ public class DefaultIotHubService implements IotHubService {
return new InstallPlanResult(false, false, "Install plan is empty", null, new ArrayList<>(), new ArrayList<>());
}
// The plan is resolved by a separate request, so by the time it is submitted an item may
// already have been installed (stale or replayed plan). Re-check against the current state
// so we never install the same item twice.
Set<UUID> alreadyInstalledItemIds = new HashSet<>(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId));
InstallPlanResult result = new InstallPlanResult();
List<InstallPlanEntry> resultEntries = new ArrayList<>();
List<String> missingItemIds = new ArrayList<>();
@ -783,6 +810,12 @@ public class DefaultIotHubService implements IotHubService {
}
case ALREADY_INSTALLED -> resultEntries.add(resultEntry);
case WILL_INSTALL -> {
if (isAlreadyInstalled(entry.getItemId(), alreadyInstalledItemIds)) {
// Installed since the plan was resolved — skip rather than create a duplicate.
resultEntry.setStatus(InstallPlanEntry.Status.ALREADY_INSTALLED);
resultEntries.add(resultEntry);
break;
}
try {
// Only the root entry receives the user's install data (target profile entityId);
// transitive deps install with defaults.
@ -798,9 +831,9 @@ public class DefaultIotHubService implements IotHubService {
entry.getName(), entry.getVersionId(), e.getMessage(), e);
resultEntry.setErrorMessage(e.getMessage());
resultEntries.add(resultEntry);
rollbackInstalledItems(user, rollbackIds);
boolean rolledBack = rollbackInstalledItems(user, rollbackIds);
result.setSuccess(false);
result.setRolledBack(true);
result.setRolledBack(rolledBack);
result.setErrorMessage("Failed to install '" + entry.getName() + "': " + e.getMessage());
result.setEntries(resultEntries);
result.setMissingItemIds(missingItemIds);
@ -818,15 +851,34 @@ public class DefaultIotHubService implements IotHubService {
return result;
}
private void rollbackInstalledItems(SecurityUser user, List<IotHubInstalledItemId> installedIds) {
/**
* Deletes the supplied installed items in reverse install order. Returns {@code true} only if
* every deletion succeeded a {@code false} result means some entities were left behind and
* the rollback is partial.
*/
private boolean rollbackInstalledItems(SecurityUser user, List<IotHubInstalledItemId> installedIds) {
boolean fullyRolledBack = true;
for (int i = installedIds.size() - 1; i >= 0; i--) {
IotHubInstalledItemId id = installedIds.get(i);
try {
deleteInstalledItem(user, id);
} catch (Exception e) {
fullyRolledBack = false;
log.error("[{}] Failed to roll back installed item {}: {}", user.getTenantId(), id, e.getMessage(), e);
}
}
return fullyRolledBack;
}
private static boolean isAlreadyInstalled(String itemId, Set<UUID> alreadyInstalledItemIds) {
if (itemId == null) {
return false;
}
try {
return alreadyInstalledItemIds.contains(UUID.fromString(itemId));
} catch (IllegalArgumentException ex) {
return false;
}
}
private static InstallPlanEntry cloneEntry(InstallPlanEntry src) {

215
application/src/test/java/org/thingsboard/server/service/iot_hub/DefaultIotHubServiceTest.java

@ -0,0 +1,215 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.iot_hub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.service.security.model.SecurityUser;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DefaultIotHubServiceTest {
private final TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
private final SecurityUser user = mock(SecurityUser.class);
@Mock
private IotHubRestClient iotHubRestClient;
@Mock
private IotHubInstalledItemService iotHubInstalledItemService;
@InjectMocks
private DefaultIotHubService service;
private void mockTenant() {
when(user.getTenantId()).thenReturn(tenantId);
}
private ObjectNode version(String id, String itemId, String type, String name, String ver) {
ObjectNode node = JacksonUtil.newObjectNode();
node.put("id", id);
node.put("itemId", itemId);
node.put("type", type);
node.put("name", name);
node.put("version", ver);
return node;
}
@Test
void resolveInstallPlan_rootOnly_noRelated_marksWillInstall() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String itemId = UUID.randomUUID().toString();
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(version(versionId, itemId, "DASHBOARD", "Root", "1.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries()).hasSize(1);
InstallPlanEntry root = plan.getEntries().get(0);
assertThat(root.isRoot()).isTrue();
assertThat(root.getStatus()).isEqualTo(InstallPlanEntry.Status.WILL_INSTALL);
assertThat(root.getItemId()).isEqualTo(itemId);
}
@Test
void resolveInstallPlan_ordersDependenciesBeforeRoot() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId))
.thenReturn(version(UUID.randomUUID().toString(), relatedItemId, "WIDGET", "Dep", "2.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries()).hasSize(2);
assertThat(plan.getEntries().get(0).getItemId()).isEqualTo(relatedItemId);
assertThat(plan.getEntries().get(0).isRoot()).isFalse();
assertThat(plan.getEntries().get(1).getItemId()).isEqualTo(rootItemId);
assertThat(plan.getEntries().get(1).isRoot()).isTrue();
}
@Test
void resolveInstallPlan_missingRelated_recordedAsMissing() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenReturn(null);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
InstallPlanEntry missing = plan.getEntries().get(0);
assertThat(missing.getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
assertThat(missing.getItemId()).isEqualTo(relatedItemId);
assertThat(missing.getErrorMessage()).isNotBlank();
}
@Test
void resolveInstallPlan_relatedFetchThrows_recordedAsMissing() {
mockTenant();
String versionId = UUID.randomUUID().toString();
String rootItemId = UUID.randomUUID().toString();
String relatedItemId = UUID.randomUUID().toString();
ObjectNode root = version(versionId, rootItemId, "DASHBOARD", "Root", "1.0");
root.putArray("relatedItems").add(relatedItemId);
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(root);
when(iotHubRestClient.getPublishedVersionByItemId(relatedItemId)).thenThrow(new RuntimeException("boom"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.MISSING);
assertThat(plan.getEntries().get(0).getErrorMessage()).contains("boom");
}
@Test
void resolveInstallPlan_alreadyInstalledRoot_marksAlreadyInstalled() {
mockTenant();
String versionId = UUID.randomUUID().toString();
UUID rootItemId = UUID.randomUUID();
when(iotHubRestClient.getVersionInfo(versionId))
.thenReturn(version(versionId, rootItemId.toString(), "DASHBOARD", "Root", "1.0"));
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of(rootItemId));
InstallPlan plan = service.resolveInstallPlan(user, versionId);
assertThat(plan.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
}
@Test
void resolveInstallPlan_nullVersion_throws() {
mockTenant();
String versionId = UUID.randomUUID().toString();
when(iotHubRestClient.getVersionInfo(versionId)).thenReturn(null);
assertThatThrownBy(() -> service.resolveInstallPlan(user, versionId))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void installPlan_emptyPlan_returnsFailureWithoutInstalling() {
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of()), null, null);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getErrorMessage()).contains("empty");
verify(iotHubInstalledItemService, never()).findInstalledItemIdsByTenantId(any());
}
@Test
void installPlan_missingEntry_collectedAndNotInstalled() {
mockTenant();
InstallPlanEntry missing = new InstallPlanEntry();
missing.setItemId(UUID.randomUUID().toString());
missing.setStatus(InstallPlanEntry.Status.MISSING);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(missing)), null, null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getMissingItemIds()).containsExactly(missing.getItemId());
assertThat(result.getRootDescriptor()).isNull();
verify(iotHubRestClient, never()).getVersionInfo(anyString());
}
@Test
void installPlan_stalePlan_skipsItemInstalledSinceResolution() {
mockTenant();
UUID itemId = UUID.randomUUID();
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(itemId.toString());
entry.setVersionId(UUID.randomUUID().toString());
entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
entry.setRoot(true);
// The item is reported as already installed at install time, even though the plan says WILL_INSTALL.
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of(itemId));
InstallPlanResult result = service.installPlan(user, new InstallPlan(null, List.of(entry)), null, null);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getEntries().get(0).getStatus()).isEqualTo(InstallPlanEntry.Status.ALREADY_INSTALLED);
// No marketplace fetch happened — the duplicate install was avoided.
verify(iotHubRestClient, never()).getVersionInfo(anyString());
}
}

10
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html

@ -195,11 +195,11 @@
}
@case ('plan') {
<button mat-button (click)="cancelPlan()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="planSummary.willInstall === 0"
(click)="installItemWithDependencies()">
{{ 'iot-hub.install-plan-confirm' | translate }}
</button>
@if (planSummary.willInstall > 0) {
<button mat-flat-button color="primary" (click)="installItemWithDependencies()">
{{ 'iot-hub.install-plan-confirm' | translate }}
</button>
}
}
@case ('installing') {
<button mat-button disabled>{{ 'action.cancel' | translate }}</button>

13
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts

@ -255,12 +255,14 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
next: (plan) => {
this.resolvingPlan = false;
const summary = this.summarizePlan(plan);
const hasDependencies = plan.entries.length > 1
// Show the plan only when there's something worth telling the user about: extra items to
// install, items already installed, or missing dependencies. A lone root with nothing to
// skip installs directly so the dialog doesn't flash a one-line "plan".
const shouldShowPlan = plan.entries.length > 1
|| summary.alreadyInstalled > 0
|| summary.missing > 0;
if (!hasDependencies) {
// Single root version, no deps, nothing to skip — install directly without showing the plan.
if (!shouldShowPlan) {
this.installItem();
return;
}
@ -332,10 +334,11 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
this.installPlan = { ...this.installPlan, entries: result.entries ?? this.installPlan.entries };
this.missingEntries = (result.entries ?? []).filter(e => e.status === InstallPlanEntryStatus.MISSING);
}
const hasMissing = (result.missingItemIds?.length ?? 0) > 0;
if (result.rootDescriptor) {
this.handleInstalledDescriptor(result.rootDescriptor, result.missingItemIds?.length > 0);
this.handleInstalledDescriptor(result.rootDescriptor, hasMissing);
} else {
this.state = (result.missingItemIds?.length ?? 0) > 0 ? 'partial' : 'success';
this.state = hasMissing ? 'partial' : 'success';
}
}

4
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3845,10 +3845,10 @@
"install-error-message": "Failed to install '{{name}}'.",
"install-plan-title": "Install '{{name}}' and its dependencies",
"install-plan-will-install": "{{count}} item(s) will be installed.",
"install-plan-will-update": "{{count}} item(s) will be updated.",
"install-plan-will-update": "{{count}} item(s) already installed and will be skipped.",
"install-plan-missing-warning": "{{count}} required item(s) could not be found on the marketplace. '{{ name }}' may not work fully without them.",
"install-plan-status-will": "Install",
"install-plan-status-installed": "Update",
"install-plan-status-installed": "Already installed",
"install-plan-status-missing": "Missing",
"install-plan-confirm": "Install",
"install-partial-title": "Installed with warnings",

Loading…
Cancel
Save