Browse Source

add tests, refactor translation map

pull/15682/head
dpinkevych 1 month ago
parent
commit
65e005fe97
  1. 7
      application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java
  2. 117
      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.ts
  4. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -127,8 +127,9 @@ public class DefaultIotHubService implements IotHubService {
/**
* Fetch + apply a single marketplace version, persist the installed-item record, and ping
* the marketplace install counter. Throws on failure so callers (cascade install) can roll back.
* Package-private so the cascade-install / rollback orchestration can be unit-tested in isolation.
*/
private IotHubInstalledItem doInstallVersion(SecurityUser user, String versionId, JsonNode data, HttpServletRequest request) throws Exception {
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(FIELD_TYPE).asText();
@ -698,7 +699,9 @@ 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(FIELD_ITEM_ID).asText();
// Null-safe: a malformed payload without an itemId must surface the friendly
// IllegalArgumentException thrown by addPlanEntry(root) below, not an NPE here.
String rootItemId = optText(rootVersion, FIELD_ITEM_ID);
JsonNode related = rootVersion.get(FIELD_RELATED_ITEMS);
if (related != null && related.isArray()) {
for (JsonNode relatedNode : related) {

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

@ -17,13 +17,19 @@ package org.thingsboard.server.service.iot_hub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.IotHubInstalledItemId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.iot_hub.DashboardInstalledItemDescriptor;
import org.thingsboard.server.common.data.iot_hub.IotHubInstalledItem;
import org.thingsboard.server.dao.iot_hub.IotHubInstalledItemService;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -34,6 +40,12 @@ 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.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ -50,6 +62,7 @@ class DefaultIotHubServiceTest {
@Mock
private IotHubInstalledItemService iotHubInstalledItemService;
@Spy
@InjectMocks
private DefaultIotHubService service;
@ -57,6 +70,23 @@ class DefaultIotHubServiceTest {
when(user.getTenantId()).thenReturn(tenantId);
}
private IotHubInstalledItem installedItem(UUID id) {
IotHubInstalledItem item = new IotHubInstalledItem();
item.setId(new IotHubInstalledItemId(id));
item.setTenantId(tenantId);
return item;
}
private InstallPlanEntry willInstall(String versionId, boolean root) {
InstallPlanEntry entry = new InstallPlanEntry();
entry.setItemId(UUID.randomUUID().toString());
entry.setVersionId(versionId);
entry.setName(root ? "Root" : "Dep");
entry.setStatus(InstallPlanEntry.Status.WILL_INSTALL);
entry.setRoot(root);
return entry;
}
private ObjectNode version(String id, String itemId, String type, String name, String ver) {
ObjectNode node = JacksonUtil.newObjectNode();
node.put("id", id);
@ -212,4 +242,91 @@ class DefaultIotHubServiceTest {
// No marketplace fetch happened — the duplicate install was avoided.
verify(iotHubRestClient, never()).getVersionInfo(anyString());
}
@Test
void installPlan_cascade_installsDependencyBeforeRoot_andRoutesDataToRootOnly() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
JsonNode data = JacksonUtil.newObjectNode().put("entityId", UUID.randomUUID().toString());
String depVersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep = willInstall(depVersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
IotHubInstalledItem rootItem = installedItem(UUID.randomUUID());
DashboardInstalledItemDescriptor rootDescriptor = new DashboardInstalledItemDescriptor();
rootItem.setDescriptor(rootDescriptor);
doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
doReturn(rootItem).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), data, request);
assertThat(result.isSuccess()).isTrue();
assertThat(result.isRolledBack()).isFalse();
assertThat(result.getRootDescriptor()).isSameAs(rootDescriptor);
assertThat(result.getMissingItemIds()).isEmpty();
assertThat(result.getEntries()).hasSize(2);
// The dependency installs before the root, and only the root receives the user's install data.
InOrder inOrder = inOrder(service);
inOrder.verify(service).doInstallVersion(eq(user), eq(depVersionId), isNull(), eq(request));
inOrder.verify(service).doInstallVersion(eq(user), eq(rootVersionId), eq(data), eq(request));
verify(service, never()).deleteInstalledItem(any(), any());
}
@Test
void installPlan_dependencyFails_rollsBackInstalledItemsInReverseOrder() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
String dep1VersionId = UUID.randomUUID().toString();
String dep2VersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep1 = willInstall(dep1VersionId, false);
InstallPlanEntry dep2 = willInstall(dep2VersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
IotHubInstalledItem dep1Item = installedItem(UUID.randomUUID());
IotHubInstalledItem dep2Item = installedItem(UUID.randomUUID());
doReturn(dep1Item).when(service).doInstallVersion(eq(user), eq(dep1VersionId), any(), any());
doReturn(dep2Item).when(service).doInstallVersion(eq(user), eq(dep2VersionId), any(), any());
doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
doNothing().when(service).deleteInstalledItem(eq(user), any());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep1, dep2, root)), null, request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.isRolledBack()).isTrue();
assertThat(result.getErrorMessage()).contains("install failed");
// Successfully installed dependencies are rolled back in reverse install order (dep2 before dep1).
InOrder inOrder = inOrder(service);
inOrder.verify(service).deleteInstalledItem(user, dep2Item.getId());
inOrder.verify(service).deleteInstalledItem(user, dep1Item.getId());
}
@Test
void installPlan_rollbackFailure_reportsNotFullyRolledBack() throws Exception {
mockTenant();
HttpServletRequest request = mock(HttpServletRequest.class);
String depVersionId = UUID.randomUUID().toString();
String rootVersionId = UUID.randomUUID().toString();
InstallPlanEntry dep = willInstall(depVersionId, false);
InstallPlanEntry root = willInstall(rootVersionId, true);
when(iotHubInstalledItemService.findInstalledItemIdsByTenantId(tenantId)).thenReturn(List.of());
IotHubInstalledItem depItem = installedItem(UUID.randomUUID());
doReturn(depItem).when(service).doInstallVersion(eq(user), eq(depVersionId), any(), any());
doThrow(new IllegalStateException("install failed")).when(service).doInstallVersion(eq(user), eq(rootVersionId), any(), any());
// Rolling back the one installed dependency itself fails — the result must report a partial rollback.
doThrow(new RuntimeException("delete failed")).when(service).deleteInstalledItem(user, depItem.getId());
InstallPlanResult result = service.installPlan(user, new InstallPlan(rootVersionId, List.of(dep, root)), null, request);
assertThat(result.isSuccess()).isFalse();
assertThat(result.isRolledBack()).isFalse();
assertThat(result.getErrorMessage()).contains("install failed");
}
}

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

@ -327,7 +327,15 @@ export class TbIotHubInstallDialogComponent extends DialogComponent<TbIotHubInst
private handlePlanResult(result: InstallPlanResult): void {
if (!result.success) {
this.state = 'error';
this.errorMessage = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name });
let message = result.errorMessage || this.translate.instant('iot-hub.install-error', { name: this.item.name });
// A failed cascade rolls back the items installed so far. When something was actually being
// installed and the rollback came back partial (rolledBack === false), some entities are left
// behind — tell the admin so they know manual cleanup may be needed. The willInstall guard
// avoids the misleading warning on the "empty plan" failure, where nothing was installed.
if (!result.rolledBack && this.planSummary.willInstall > 0) {
message += ' ' + this.translate.instant('iot-hub.install-rollback-partial');
}
this.errorMessage = message;
return;
}
if (this.installPlan) {

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

@ -3853,6 +3853,7 @@
"install-plan-confirm": "Install",
"install-partial-title": "Installed with warnings",
"install-partial-desc": "'{{name}}' was installed, but some required items were missing.",
"install-rollback-partial": "Some of the items installed before the failure could not be removed automatically and may need to be deleted manually.",
"check-for-updates": "Check for updates",
"updates": "Updates",
"up-to-date": "Up to date",

Loading…
Cancel
Save