Browse Source

Merge remote-tracking branch 'origin/lts-4.2' into release-4.2

release-4.2
Viacheslav Klimov 1 month ago
parent
commit
df81d1a973
Failed to extract signature
  1. 4
      application/src/main/data/upgrade/lts/4.2.2.3/schema_update.sql
  2. 2
      application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java
  3. 29
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  4. 12
      application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
  5. 30
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java
  6. 147
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java
  7. 61
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsVersion.java
  8. 73
      application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java
  9. 7
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
  10. 60
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  11. 50
      application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java
  12. 219
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java
  13. 248
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java
  14. 78
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java
  15. 96
      application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java
  16. 179
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

4
application/src/main/data/upgrade/lts/schema_update.sql → application/src/main/data/upgrade/lts/4.2.2.3/schema_update.sql

@ -14,10 +14,6 @@
-- limitations under the License.
--
-- LTS cumulative schema update file.
-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.).
-- This file is executed by SystemPatchApplier on every version increase within the LTS family.
-- IOT HUB INSTALLED ITEM START
CREATE TABLE IF NOT EXISTS iot_hub_installed_item (

2
application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java

@ -23,6 +23,8 @@ public interface DatabaseSchemaSettingsService {
void updateSchemaVersion();
void updateSchemaVersion(String version);
String getPackageSchemaVersion();
String getDbSchemaVersion();

29
application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java

@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.thingsboard.server.service.install.lts.LtsVersion;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.util.Map;
@ -74,7 +75,12 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
@Override
public void updateSchemaVersion() {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + getPackageSchemaVersionForDb());
updateSchemaVersion(getPackageSchemaVersion());
}
@Override
public void updateSchemaVersion(String version) {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + toDbVersion(version));
}
@Override
@ -123,14 +129,12 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
}
private long getPackageSchemaVersionForDb() {
String[] versionParts = getPackageSchemaVersion().split("\\.");
long major = Integer.parseInt(versionParts[0]);
long minor = Integer.parseInt(versionParts[1]);
long maintenance = Integer.parseInt(versionParts[2]);
long patch = Integer.parseInt(versionParts[3]);
return toDbVersion(getPackageSchemaVersion());
}
return major * 1_000_000_000L + minor * 1_000_000L + maintenance * 1000L + patch;
private long toDbVersion(String version) {
LtsVersion v = LtsVersion.parse(version);
return v.major() * 1_000_000_000L + v.minor() * 1_000_000L + v.maintenance() * 1000L + v.patch();
}
private void onSchemaSettingsError(String message) {
@ -139,14 +143,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
}
private String normalizeVersion(String version) {
String[] parts = version.split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return major + "." + minor + "." + maintenance + "." + patch;
return LtsVersion.parse(version).toString();
}
}

12
application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java

@ -23,6 +23,7 @@ import org.springframework.jdbc.core.StatementCallback;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -42,19 +43,26 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
private final InstallScripts installScripts;
private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService;
public SqlDatabaseUpgradeService(InstallScripts installScripts, JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
public SqlDatabaseUpgradeService(InstallScripts installScripts, JdbcTemplate jdbcTemplate,
PlatformTransactionManager transactionManager,
DatabaseSchemaSettingsService schemaSettingsService,
LtsMigrationService ltsMigrationService) {
this.installScripts = installScripts;
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setTimeout((int) TimeUnit.MINUTES.toSeconds(120));
this.schemaSettingsService = schemaSettingsService;
this.ltsMigrationService = ltsMigrationService;
}
@Override
public void upgradeDatabase() {
log.info("Updating schema...");
loadSql(getSchemaUpdateFile("basic"));
loadSql(getSchemaUpdateFile("lts"));
ltsMigrationService.runSchemaMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
log.info("Schema updated.");
}

30
application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java

@ -0,0 +1,30 @@
/**
* 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;
/**
* One LTS migration, keyed by the version it ships in (e.g. "4.2.2.3").
* The runner runs the version's data/upgrade/lts/<version>/schema_update.sql by convention
* (if present), then calls {@link #apply()} for any programmatic work beyond SQL.
* Most migrations are SQL-only and only override {@link #getVersion()}.
*/
public interface LtsMigration {
String getVersion();
default void apply() {
}
}

147
application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java

@ -0,0 +1,147 @@
/**
* 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.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j
@Component
@TbCoreComponent
public class LtsMigrationService {
private static final String SCHEMA_UPDATE_SQL = "schema_update.sql";
/** A migration paired with its parsed version, so the version is parsed exactly once per bean. */
private record VersionedMigration(LtsVersion version, LtsMigration migration) {}
private final JdbcTemplate jdbcTemplate;
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final TransactionTemplate transactionTemplate;
private final List<VersionedMigration> migrations;
public LtsMigrationService(JdbcTemplate jdbcTemplate,
InstallScripts installScripts,
DatabaseSchemaSettingsService schemaSettingsService,
PlatformTransactionManager transactionManager,
List<LtsMigration> migrations) {
this.jdbcTemplate = jdbcTemplate;
this.installScripts = installScripts;
this.schemaSettingsService = schemaSettingsService;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.migrations = validateAndSort(migrations);
log.info("Discovered {} LTS migration(s): {}", this.migrations.size(),
this.migrations.stream().map(vm -> vm.migration().getVersion()).toList());
}
private static List<VersionedMigration> validateAndSort(List<LtsMigration> migrations) {
Set<String> seen = new HashSet<>();
List<VersionedMigration> versioned = new ArrayList<>();
for (LtsMigration m : migrations) {
LtsVersion version = LtsVersion.parse(m.getVersion()); // fail loud on unparseable version
if (!seen.add(m.getVersion())) {
throw new IllegalStateException("Duplicate LTS migration version: " + m.getVersion());
}
versioned.add(new VersionedMigration(version, m));
}
return versioned.stream()
.sorted(Comparator.comparing(VersionedMigration::version))
.toList();
}
/** No-downtime path: per migration in (from, to] run SQL, then apply(), then record the version. */
public void applyMigrations(String fromVersion, String toVersion) {
for (VersionedMigration vm : select(fromVersion, toVersion)) {
LtsMigration migration = vm.migration();
String version = migration.getVersion();
transactionTemplate.executeWithoutResult(status -> {
runSchemaUpdate(version);
migration.apply();
schemaSettingsService.updateSchemaVersion(version);
});
log.info("Applied LTS migration {}", version);
}
}
/** Offline major-upgrade schema phase: per migration in (from, to] run SQL only. No version record. */
public void runSchemaMigrations(String fromVersion, String toVersion) {
for (VersionedMigration vm : select(fromVersion, toVersion)) {
String version = vm.migration().getVersion();
transactionTemplate.executeWithoutResult(status -> runSchemaUpdate(version));
log.info("Applied LTS schema migration {}", version);
}
}
/** Offline major-upgrade data phase: per migration in (from, to] run apply() only. No SQL, no version record. */
public void runDataMigrations(String fromVersion, String toVersion) {
for (VersionedMigration vm : select(fromVersion, toVersion)) {
vm.migration().apply();
log.info("Applied LTS data migration {}", vm.migration().getVersion());
}
}
private List<VersionedMigration> select(String fromVersion, String toVersion) {
LtsVersion from = LtsVersion.parse(fromVersion);
LtsVersion to = LtsVersion.parse(toVersion);
return migrations.stream()
.filter(vm -> isInRangeForTargetFamily(vm.version(), from, to))
.toList();
}
// Run only migrations whose family matches the target version. Older-family
// migrations (e.g. 4.2.x) ride onto newer-family branches (e.g. 4.3.x) via the
// release-merge cascade, but each branch's own family of migrations is
// self-contained (newer-family copies reproduce the older schema/data changes),
// so a cross-family upgrade is fully handled by the target-family migrations.
// Excluding the dormant older-family beans avoids double-processing.
static boolean isInRangeForTargetFamily(LtsVersion version, LtsVersion from, LtsVersion to) {
return version.sameFamily(to)
&& version.compareTo(from) > 0
&& version.compareTo(to) <= 0;
}
private void runSchemaUpdate(String version) {
Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", version, SCHEMA_UPDATE_SQL);
if (!Files.exists(sqlFile)) {
log.trace("No LTS schema update file for version {} at {}", version, sqlFile);
return;
}
try {
jdbcTemplate.execute(Files.readString(sqlFile));
log.info("Applied LTS SQL schema update from {}", sqlFile);
} catch (IOException e) {
throw new UncheckedIOException("Failed to read LTS schema update file: " + sqlFile, e);
}
}
}

61
application/src/main/java/org/thingsboard/server/service/install/lts/LtsVersion.java

@ -0,0 +1,61 @@
/**
* 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;
public record LtsVersion(int major, int minor, int maintenance, int patch) implements Comparable<LtsVersion> {
public static LtsVersion parse(String version) {
if (version == null) {
throw new IllegalArgumentException("Version is null");
}
String[] parts = version.split("\\.");
try {
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return new LtsVersion(major, minor, maintenance, patch);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid version: " + version, e);
}
}
public boolean sameFamily(LtsVersion other) {
return major == other.major && minor == other.minor;
}
@Override
public int compareTo(LtsVersion o) {
int c = Integer.compare(major, o.major);
if (c != 0) {
return c;
}
c = Integer.compare(minor, o.minor);
if (c != 0) {
return c;
}
c = Integer.compare(maintenance, o.maintenance);
if (c != 0) {
return c;
}
return Integer.compare(patch, o.patch);
}
@Override
public String toString() {
return major + "." + minor + "." + maintenance + "." + patch;
}
}

73
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<String> 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<WidgetTypeDetails> 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);
}
}

7
application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java

@ -33,7 +33,9 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.component.RuleNodeClassInfo;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.DbUpgradeExecutorService;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.utils.TbNodeUpgradeUtils;
import java.util.ArrayList;
@ -52,12 +54,13 @@ public class DefaultDataUpdateService implements DataUpdateService {
private final RuleChainService ruleChainService;
private final ComponentDiscoveryService componentDiscoveryService;
private final DbUpgradeExecutorService executorService;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService;
@Override
public void updateData() throws Exception {
log.info("Updating data ...");
//TODO: should be cleaned after each release
ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
log.info("Data updated.");
}

60
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -35,6 +35,8 @@ import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.install.lts.LtsVersion;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.io.IOException;
@ -74,6 +76,7 @@ public class SystemPatchApplier {
private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService;
private final LtsMigrationService ltsMigrationService;
@PostConstruct
private void init() {
@ -101,7 +104,9 @@ public class SystemPatchApplier {
}
try {
updateLtsSqlSchema();
String dbVersion = schemaSettingsService.getDbSchemaVersion();
String packageVersion = schemaSettingsService.getPackageSchemaVersion();
ltsMigrationService.applyMigrations(dbVersion, packageVersion);
updateSqlViews();
log.info("Updated sql database views");
@ -129,15 +134,17 @@ public class SystemPatchApplier {
log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion);
VersionInfo packageVersionInfo = parseVersion(packageVersion);
VersionInfo dbVersionInfo = parseVersion(dbVersion);
if (packageVersionInfo == null || dbVersionInfo == null) {
LtsVersion packageVersionInfo;
LtsVersion dbVersionInfo;
try {
packageVersionInfo = LtsVersion.parse(packageVersion);
dbVersionInfo = LtsVersion.parse(dbVersion);
} catch (IllegalArgumentException e) {
log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion);
return false;
}
if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
if (!packageVersionInfo.sameFamily(dbVersionInfo) || packageVersionInfo.compareTo(dbVersionInfo) <= 0) {
return false;
}
@ -145,31 +152,6 @@ public class SystemPatchApplier {
return true;
}
private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
return false;
}
if (packageVersion.maintenance != dbVersion.maintenance) {
return packageVersion.maintenance > dbVersion.maintenance;
}
return packageVersion.patch > dbVersion.patch;
}
private void updateLtsSqlSchema() {
Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
if (!Files.exists(sqlFile)) {
log.trace("LTS schema update file does not exist: {}", sqlFile);
return;
}
try {
String sql = Files.readString(sqlFile);
jdbcTemplate.execute(sql);
log.info("Applied LTS SQL schema update from {}", sqlFile);
} catch (IOException e) {
throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
}
}
private void updateSqlViews() {
try {
URL schemaViewsUrl = Resources.getResource(SCHEMA_VIEWS_SQL);
@ -428,20 +410,6 @@ public class SystemPatchApplier {
}
}
private VersionInfo parseVersion(String version) {
try {
String[] parts = version.split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return new VersionInfo(major, minor, maintenance, patch);
} catch (Exception e) {
log.error("Failed to parse version: {}", version, e);
return null;
}
}
private Stream<Path> listDir(Path dir) {
try {
return Files.list(dir);
@ -452,8 +420,6 @@ public class SystemPatchApplier {
}
}
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
public record WidgetTypeStats(int created, int updated) {}
private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }

50
application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java

@ -0,0 +1,50 @@
/**
* 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;
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.springframework.jdbc.core.JdbcTemplate;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class DefaultDatabaseSchemaSettingsServiceTest {
@Mock
private ProjectInfo projectInfo;
@Mock
private JdbcTemplate jdbcTemplate;
@InjectMocks
private DefaultDatabaseSchemaSettingsService service;
@Test
void updateSchemaVersionWithExplicitVersionEncodesAsLong() {
service.updateSchemaVersion("4.2.2.3");
verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4002002003");
}
@Test
void updateSchemaVersionWithShortVersionPadsMissingComponents() {
service.updateSchemaVersion("4.3");
verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4003000000");
}
}

219
application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java

@ -0,0 +1,219 @@
/**
* 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 com.fasterxml.jackson.databind.JsonNode;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.thingsboard.common.util.JacksonUtil;
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.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.InstallScripts;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class LtsMigrationIntegrationTest extends AbstractControllerTest {
private static final long V_4_2_2_2 = 4_002_002_002L;
private static final long V_4_2_2_3 = 4_002_002_003L;
private static final String OBSOLETE_ALIAS = "air_quality";
// Versions whose family is older than the current package family ship SQL-less beans intentionally
// (their schema/data changes are reproduced by the current-family migrations), so a missing dir is OK.
private static final Set<String> SQL_LESS_ALLOWED = Set.of();
@Autowired
private LtsMigrationService ltsMigrationService;
@Autowired
private WidgetsBundleService widgetsBundleService;
@Autowired
private WidgetTypeService widgetTypeService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private InstallScripts installScripts;
@Autowired
private List<LtsMigration> migrations;
private Long originalSchemaVersion;
private WidgetsBundleId bundleId;
private WidgetTypeId widgetTypeId;
@Before
public void setUp() {
// The test-context DB (DaoSqlTest) creates tb_schema_settings empty, so ensure the baseline
// version row a real installed DB always has exists before driving the migration.
originalSchemaVersion = jdbcTemplate.query("SELECT schema_version FROM tb_schema_settings",
rs -> rs.next() ? rs.getLong(1) : null);
if (originalSchemaVersion == null) {
jdbcTemplate.execute("INSERT INTO tb_schema_settings (schema_version, product) VALUES (" + V_4_2_2_2 + ", 'CE')");
}
// Seed an obsolete system widget bundle with one (non-deprecated) widget type linked to it.
WidgetsBundle bundle = new WidgetsBundle();
bundle.setTenantId(TenantId.SYS_TENANT_ID);
bundle.setAlias(OBSOLETE_ALIAS);
bundle.setTitle("Air quality");
bundleId = widgetsBundleService.saveWidgetsBundle(bundle).getId();
WidgetTypeDetails type = new WidgetTypeDetails();
type.setTenantId(TenantId.SYS_TENANT_ID);
type.setFqn("air_quality_sample_" + UUID.randomUUID());
type.setName("Air quality sample");
type.setDescriptor(JacksonUtil.fromString("{ \"type\": \"latest\" }", JsonNode.class));
WidgetTypeDetails saved = widgetTypeService.saveWidgetType(type);
widgetTypeId = saved.getId();
widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, bundleId, List.of(saved.getFqn()));
// Pretend the DB is at 4.2.2.2 so the 4.2.2.3 migration is in range (4.2.2.2, 4.2.2.3].
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + V_4_2_2_2);
}
@After
public void tearDown() {
// Restore the schema version (or drop the row we seeded) and clean up whatever the migration left behind.
if (originalSchemaVersion != null) {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + originalSchemaVersion);
} else {
jdbcTemplate.execute("DELETE FROM tb_schema_settings");
}
if (widgetTypeId != null && widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId) != null) {
widgetTypeService.deleteWidgetType(TenantId.SYS_TENANT_ID, widgetTypeId);
}
WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS);
if (bundle != null) {
widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
}
}
@Test
public void appliesSqlDeprecatesTypesDeletesBundleAndRecordsVersion() {
ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
// 1. The version's SQL ran: iot_hub_installed_item table now exists.
assertTrue(tableExists("iot_hub_installed_item"));
// 2. The version was recorded.
assertEquals(Long.valueOf(V_4_2_2_3),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
// 3. The obsolete bundle entity was deleted.
assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
// 4. The widget type was KEPT and marked deprecated.
WidgetTypeDetails type = widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId);
assertNotNull(type);
assertTrue(type.isDeprecated());
}
@Test
public void reRunFromCurrentVersionIsNoOp() {
ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
// Re-running from the now-current version selects an empty range — nothing changes, nothing throws.
ltsMigrationService.applyMigrations("4.2.2.3", "4.2.2.3");
assertEquals(Long.valueOf(V_4_2_2_3),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
assertTrue(widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId).isDeprecated());
}
@Test
public void offlinePathRunsSchemaThenDataButRecordsNoVersion() {
// Drive the offline major-upgrade path over the real supported range against the real DB.
ltsMigrationService.runSchemaMigrations("4.2.2.2", "4.2.2.3");
// (a) the schema effects landed: the table the 4.2.2.3 SQL creates now exists.
assertTrue(tableExists("iot_hub_installed_item"));
// (c) the offline schema phase records NO schema version (unlike applyMigrations).
assertEquals(Long.valueOf(V_4_2_2_2),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
ltsMigrationService.runDataMigrations("4.2.2.2", "4.2.2.3");
// (b) the data apply() ran: the obsolete bundle was deleted and its type marked deprecated.
assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
WidgetTypeDetails type = widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId);
assertNotNull(type);
assertTrue(type.isDeprecated());
// (c) the offline data phase also records NO schema version.
assertEquals(Long.valueOf(V_4_2_2_2),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
}
@Test
public void migrationDirectoriesAndBeansStayInSyncBothWays() {
Path ltsDir = Paths.get(installScripts.getDataDir(), "upgrade", "lts");
Set<String> dirVersions = listDirVersions(ltsDir);
Set<String> beanVersions = migrations.stream().map(LtsMigration::getVersion).collect(Collectors.toSet());
// Every on-disk migration directory must have a registered bean with the same version.
// Otherwise select() (which iterates beans, not dirs) silently skips the SQL dir.
Set<String> dirsWithoutBean = dirVersions.stream()
.filter(v -> !beanVersions.contains(v))
.collect(Collectors.toSet());
assertTrue("Migration directories without a registered LtsMigration bean: " + dirsWithoutBean,
dirsWithoutBean.isEmpty());
// Every registered bean must have a matching directory, unless it is explicitly allowed to be SQL-less.
// Otherwise a typo'd dir name silently runs no SQL for that bean.
Set<String> beansWithoutDir = beanVersions.stream()
.filter(v -> !dirVersions.contains(v))
.filter(v -> !SQL_LESS_ALLOWED.contains(v))
.collect(Collectors.toSet());
assertTrue("Registered LtsMigration beans without a matching directory (and not SQL-less allowed): " + beansWithoutDir,
beansWithoutDir.isEmpty());
}
private Set<String> listDirVersions(Path ltsDir) {
if (!Files.isDirectory(ltsDir)) {
return Set.of();
}
try (Stream<Path> entries = Files.list(ltsDir)) {
return entries.filter(Files::isDirectory)
.map(p -> p.getFileName().toString())
.collect(Collectors.toSet());
} catch (IOException e) {
throw new UncheckedIOException("Failed to list LTS migration directories: " + ltsDir, e);
}
}
private boolean tableExists(String table) {
Boolean exists = jdbcTemplate.queryForObject(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = ?)", Boolean.class, table);
return Boolean.TRUE.equals(exists);
}
}

248
application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java

@ -0,0 +1,248 @@
/**
* 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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LtsMigrationServiceTest {
private JdbcTemplate jdbcTemplate;
private InstallScripts installScripts;
private DatabaseSchemaSettingsService schemaSettingsService;
private PlatformTransactionManager txManager;
@TempDir
Path dataDir;
@BeforeEach
void setUp() {
jdbcTemplate = Mockito.mock(JdbcTemplate.class);
installScripts = Mockito.mock(InstallScripts.class);
schemaSettingsService = Mockito.mock(DatabaseSchemaSettingsService.class);
txManager = Mockito.mock(PlatformTransactionManager.class);
when(txManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
}
private void writeSql(String version, String sql) throws Exception {
Path dir = dataDir.resolve("upgrade").resolve("lts").resolve(version);
Files.createDirectories(dir);
Files.writeString(dir.resolve("schema_update.sql"), sql);
}
/** Records which apply() hooks fired, in order. */
private LtsMigration migration(String version, List<String> applied) {
return new LtsMigration() {
@Override public String getVersion() { return version; }
@Override public void apply() { applied.add(version); }
};
}
private LtsMigrationService service(List<LtsMigration> migrations) {
return new LtsMigrationService(jdbcTemplate, installScripts, schemaSettingsService, txManager, migrations);
}
@Test
void selectsOnlyInRangeMigrationsInAscendingOrder() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
writeSql("4.2.2.4", "SELECT 2;");
// intentionally unsorted input; service must sort ascending
LtsMigrationService service = service(List.of(
migration("4.2.2.4", applied),
migration("4.2.2.2", applied),
migration("4.2.2.3", applied)));
service.applyMigrations("4.2.2.2", "4.2.2.3");
// only 4.2.2.3 is in (4.2.2.2, 4.2.2.3]
assertEquals(List.of("4.2.2.3"), applied);
verify(jdbcTemplate).execute("SELECT 1;");
verify(jdbcTemplate, never()).execute("SELECT 2;");
verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
}
@Test
void appliesAllInRangeAndRecordsEachVersion() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
writeSql("4.2.2.4", "SELECT 2;");
LtsMigrationService service = service(List.of(
migration("4.2.2.3", applied), migration("4.2.2.4", applied)));
service.applyMigrations("4.2.2.2", "4.2.2.4");
assertEquals(List.of("4.2.2.3", "4.2.2.4"), applied);
verify(jdbcTemplate).execute("SELECT 1;");
verify(jdbcTemplate).execute("SELECT 2;");
verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
verify(schemaSettingsService).updateSchemaVersion("4.2.2.4");
}
@Test
void skipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() {
List<String> applied = new ArrayList<>();
// 4.2.2.3 is carried onto a 4.3 branch by the release-merge cascade but must stay dormant:
// a cross-family 4.2 -> 4.3 upgrade is handled entirely by the 4.3-family migrations.
LtsMigrationService service = service(List.of(
migration("4.2.2.3", applied),
migration("4.3.1.2", applied),
migration("4.3.1.3", applied)));
service.runDataMigrations("4.2.2.2", "4.3.1.3");
assertEquals(List.of("4.3.1.2", "4.3.1.3"), applied);
}
@Test
void applyMigrationsSkipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() {
List<String> applied = new ArrayList<>();
// The dormant 4.2.2.3 bean must not apply or record on a cross-family 4.2 -> 4.3 upgrade.
LtsMigrationService service = service(List.of(
migration("4.2.2.3", applied),
migration("4.3.1.2", applied),
migration("4.3.1.3", applied)));
service.applyMigrations("4.2.2.2", "4.3.1.3");
assertEquals(List.of("4.3.1.2", "4.3.1.3"), applied);
verify(schemaSettingsService, never()).updateSchemaVersion("4.2.2.3");
verify(schemaSettingsService).updateSchemaVersion("4.3.1.2");
verify(schemaSettingsService).updateSchemaVersion("4.3.1.3");
}
@Test
void runSchemaMigrationsSkipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
writeSql("4.3.1.2", "SELECT 2;");
writeSql("4.3.1.3", "SELECT 3;");
LtsMigrationService service = service(List.of(
migration("4.2.2.3", applied),
migration("4.3.1.2", applied),
migration("4.3.1.3", applied)));
service.runSchemaMigrations("4.2.2.2", "4.3.1.3");
// The dormant 4.2.2.3 SQL must not run; the 4.3-family SQL must.
verify(jdbcTemplate, never()).execute("SELECT 1;");
verify(jdbcTemplate).execute("SELECT 2;");
verify(jdbcTemplate).execute("SELECT 3;");
}
@Test
void isInRangeForTargetFamilyPredicate() {
LtsVersion from = LtsVersion.parse("4.3.1.1");
LtsVersion to = LtsVersion.parse("4.3.1.3");
// same-family in range
assertTrue(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.3.1.2"), from, to));
// older-family bean within the numeric range but wrong family
assertFalse(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.2.2.3"), from, to));
// the target itself (upper boundary, inclusive)
assertTrue(LtsMigrationService.isInRangeForTargetFamily(to, from, to));
// at from (lower boundary, exclusive)
assertFalse(LtsMigrationService.isInRangeForTargetFamily(from, from, to));
// below from
assertFalse(LtsMigrationService.isInRangeForTargetFamily(LtsVersion.parse("4.3.1.0"), from, to));
}
@Test
void reRunAtCurrentVersionIsNoOp() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
service.applyMigrations("4.2.2.3", "4.2.2.3");
assertEquals(List.of(), applied);
verify(jdbcTemplate, never()).execute(anyString());
verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
}
@Test
void runSchemaMigrationsRunsSqlButNeverAppliesOrRecords() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
service.runSchemaMigrations("4.2.2.2", "4.2.2.3");
verify(jdbcTemplate).execute("SELECT 1;");
assertEquals(List.of(), applied);
verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
}
@Test
void runDataMigrationsAppliesButNeverRunsSqlOrRecords() throws Exception {
List<String> applied = new ArrayList<>();
writeSql("4.2.2.3", "SELECT 1;");
LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
service.runDataMigrations("4.2.2.2", "4.2.2.3");
assertEquals(List.of("4.2.2.3"), applied);
verify(jdbcTemplate, never()).execute(anyString());
verify(schemaSettingsService, never()).updateSchemaVersion(anyString());
}
@Test
void migrationWithoutSqlFileStillAppliesAndRecords() {
List<String> applied = new ArrayList<>();
LtsMigrationService service = service(List.of(migration("4.2.2.3", applied)));
service.applyMigrations("4.2.2.2", "4.2.2.3");
assertEquals(List.of("4.2.2.3"), applied);
verify(jdbcTemplate, never()).execute(anyString());
verify(schemaSettingsService).updateSchemaVersion("4.2.2.3");
}
@Test
void failsLoudOnDuplicateVersion() {
List<String> applied = new ArrayList<>();
assertThrows(IllegalStateException.class, () -> service(List.of(
migration("4.2.2.3", applied), migration("4.2.2.3", applied))));
}
@Test
void failsLoudOnUnparseableVersion() {
List<String> applied = new ArrayList<>();
assertThrows(IllegalArgumentException.class, () -> service(List.of(migration("nope", applied))));
}
}

78
application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java

@ -0,0 +1,78 @@
/**
* 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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LtsVersionTest {
@ParameterizedTest
@CsvSource({
"4.2.1, 4, 2, 1, 0",
"4.2.0, 4, 2, 0, 0",
"4.2, 4, 2, 0, 0",
"4.0.1.2, 4, 0, 1, 2",
"4, 4, 0, 0, 0",
"10.20.30.40, 10, 20, 30, 40"
})
void parsesValidVersions(String input, int major, int minor, int maintenance, int patch) {
LtsVersion v = LtsVersion.parse(input);
assertEquals(major, v.major());
assertEquals(minor, v.minor());
assertEquals(maintenance, v.maintenance());
assertEquals(patch, v.patch());
}
@ParameterizedTest
@ValueSource(strings = {"invalid", "a.b.c", "1.2.y.x", "1.x.3", ""})
void throwsOnInvalidVersions(String input) {
assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(input));
}
@Test
void throwsOnNull() {
assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(null));
}
@Test
void comparesByEachComponent() {
assertTrue(LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.2")) > 0);
assertTrue(LtsVersion.parse("4.2.2.0").compareTo(LtsVersion.parse("4.2.1.9")) > 0);
assertTrue(LtsVersion.parse("4.3.0.0").compareTo(LtsVersion.parse("4.2.9.9")) > 0);
assertEquals(0, LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.3")));
}
@Test
void sameFamilyComparesMajorAndMinorOnly() {
assertTrue(LtsVersion.parse("4.2.2.3").sameFamily(LtsVersion.parse("4.2.0.0")));
assertFalse(LtsVersion.parse("4.3.0.0").sameFamily(LtsVersion.parse("4.2.9.9")));
assertFalse(LtsVersion.parse("5.2.0.0").sameFamily(LtsVersion.parse("4.2.0.0")));
}
@Test
void toStringIsFourComponentDotForm() {
assertEquals("4.2.2.3", LtsVersion.parse("4.2.2.3").toString());
assertEquals("4.2.0.0", LtsVersion.parse("4.2").toString());
}
}

96
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());
}
}

179
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -21,7 +21,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@ -39,6 +38,7 @@ import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
@ -56,7 +56,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@ -91,46 +90,15 @@ public class SystemPatchApplierTest {
@Mock
private ImageService imageService;
@Mock
private LtsMigrationService ltsMigrationService;
@InjectMocks
private SystemPatchApplier reconciler;
@TempDir
Path tempDir;
@ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}")
@CsvSource({
"4.2.1, 4, 2, 1, 0",
"4.2.0, 4, 2, 0, 0",
"4.2, 4, 2, 0, 0",
"4.0.1.2, 4, 0, 1, 2",
"4, 4, 0, 0, 0",
"1.0.5.7, 1, 0, 5, 7",
"10.20.30.40, 10, 20, 30, 40",
"0.0.1, 0, 0, 1, 0"
})
void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString);
assertNotNull(version, "Version should not be null for: " + versionString);
assertEquals(expectedMajor, version.major(), "Major version mismatch");
assertEquals(expectedMinor, version.minor(), "Minor version mismatch");
assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch");
assertEquals(expectedPatch, version.patch(), "Patch version mismatch");
}
@ParameterizedTest(name = "Parse invalid version: {0}")
@CsvSource({
"invalid",
"a.b.c",
"1.2.y.x",
"''",
"1.x.3"
})
void testParseInvalidVersion(String invalidVersion) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion);
assertNull(version, "Version should be null for invalid input: " + invalidVersion);
}
@Test
void whenLockIsNotAcquired_thenAcquiredIsSuccess() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true);
@ -449,78 +417,6 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
// --- isVersionIncreased tests ---
@ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
@MethodSource("provideVersionComparisonTestCases")
void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
assertEquals(expected, result, testName);
}
private static Stream<Arguments> provideVersionComparisonTestCases() {
return Stream.of(
// Maintenance digit increases within same LTS family
Arguments.of("maintenance increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("maintenance increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Patch digit increases within same maintenance
Arguments.of("patch increased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("patch increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
// Both maintenance and patch increased
Arguments.of("maintenance and patch both increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Maintenance increased, patch value is lower (irrelevant — maintenance wins)
Arguments.of("maintenance increased, patch is lower",
new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
// Same version — no increase
Arguments.of("same version",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("same version with non-zero parts",
new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
// Decreased versions — no increase
Arguments.of("maintenance decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
Arguments.of("patch decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
// Different major — different family, skip
Arguments.of("different major",
new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("major decreased",
new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
// Different minor — different LTS family, skip
Arguments.of("minor increased (different LTS family)",
new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("minor decreased",
new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
);
}
// --- isVersionChanged tests ---
@Test
@ -563,76 +459,22 @@ public class SystemPatchApplierTest {
assertFalse(result);
}
// --- updateLtsSqlSchema tests ---
@Test
void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
}
@Test
void whenLtsSqlFileDoesNotExist_thenSkips() {
Path dataDir = tempDir.resolve("data");
// Don't create the file
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate, never()).execute(anyString());
}
@Test
void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
String sql = "DO $$ BEGIN\n" +
" IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
" CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
" END IF;\n" +
"END $$;\n" +
"ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute(sql);
}
// --- applyPatchIfNeeded flow tests ---
@Test
void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
void whenVersionIncreased_thenAppliesMigrationsBeforeViewsAndWidgets() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(tempDir.resolve("widget_types"));
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
// LTS SQL was executed
verify(jdbcTemplate).execute("SELECT 1;");
// Schema version was updated
verify(ltsMigrationService).applyMigrations("4.3.0.0", "4.3.1.0");
verify(schemaSettingsService).updateSchemaVersion();
}
@ -668,17 +510,16 @@ public class SystemPatchApplierTest {
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(schemaSettingsService).updateSchemaVersion();
verify(ltsMigrationService).applyMigrations("4.3.1.0", "4.3.2.0");
}
@Test

Loading…
Cancel
Save