diff --git a/application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java new file mode 100644 index 0000000000..98701cf8e0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java @@ -0,0 +1,73 @@ +/** + * 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.install.lts; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.List; + +@Slf4j +@Component +@TbCoreComponent +@RequiredArgsConstructor +public class V4_2_2_3Migration implements LtsMigration { + + // Match on the bundle ALIAS, not the JSON filename stem. + // The "industrial_widgets" bundle shipped in a misspelled file (industial_widgets.json), + // but the alias inside it is correctly spelled. + private static final List OBSOLETE_BUNDLE_ALIASES = List.of( + "air_quality", "indoor_environment", "industrial_widgets", "outdoor_environment"); + + private final WidgetsBundleService widgetsBundleService; + private final WidgetTypeService widgetTypeService; + + @Override + public String getVersion() { + return "4.2.2.3"; + } + + @Override + public void apply() { + for (String alias : OBSOLETE_BUNDLE_ALIASES) { + deprecateTypesAndDeleteBundle(alias); + } + } + + // Marks the bundle's widget types as deprecated (keeping the type entities) and deletes ONLY the bundle entity. + private void deprecateTypesAndDeleteBundle(String alias) { + WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias); + if (bundle == null) { + return; // already removed — idempotent + } + List types = widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId()); + for (WidgetTypeDetails type : types) { + if (!type.isDeprecated()) { + type.setDeprecated(true); + widgetTypeService.saveWidgetType(type); + } + } + widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId()); + log.info("Deprecated {} widget type(s) and removed obsolete system widget bundle: {}", types.size(), alias); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java new file mode 100644 index 0000000000..083a61c974 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java @@ -0,0 +1,96 @@ +/** + * 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.install.lts; + +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.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class V4_2_2_3MigrationTest { + + @Mock + private WidgetsBundleService widgetsBundleService; + + @Mock + private WidgetTypeService widgetTypeService; + + @InjectMocks + private V4_2_2_3Migration migration; + + @Test + void versionIs4223() { + assertEquals("4.2.2.3", migration.getVersion()); + } + + @Test + void deprecatesTypesThenDeletesBundleEntityOnly() { + WidgetsBundle bundle = new WidgetsBundle(); + bundle.setId(new WidgetsBundleId(UUID.randomUUID())); + bundle.setAlias("air_quality"); + + WidgetTypeDetails fresh = new WidgetTypeDetails(); + fresh.setId(new WidgetTypeId(UUID.randomUUID())); + fresh.setDeprecated(false); + WidgetTypeDetails already = new WidgetTypeDetails(); + already.setId(new WidgetTypeId(UUID.randomUUID())); + already.setDeprecated(true); + + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "air_quality")).thenReturn(bundle); + when(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId())).thenReturn(List.of(fresh, already)); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "indoor_environment")).thenReturn(null); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "industrial_widgets")).thenReturn(null); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "outdoor_environment")).thenReturn(null); + + migration.apply(); + + // only the previously non-deprecated type is re-saved, now deprecated + verify(widgetTypeService).saveWidgetType(argThat(WidgetTypeDetails::isDeprecated)); + // widget types are NOT deleted + verify(widgetTypeService, never()).deleteWidgetTypesByBundleId(any(), any()); + // only the bundle entity is removed + verify(widgetsBundleService).deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId()); + } + + @Test + void absentBundleIsNoOp() { + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(any(), any())).thenReturn(null); + + migration.apply(); + + verify(widgetTypeService, never()).saveWidgetType(any()); + verify(widgetsBundleService, never()).deleteWidgetsBundle(any(), any()); + } +}