Browse Source

Added support for new system widgets in patch upgrades

SystemPatchApplier now creates widget types missing from the DB
instead of throwing, and merges new fqns into existing system bundles.
Bundle creation and inline widgetTypes in bundle JSONs are explicitly
rejected as out-of-scope for patch upgrades.
pull/15574/head
Oleksandra Matviienko 5 months ago
parent
commit
ab578a5f87
  1. 6
      application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
  2. 128
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  3. 269
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

6
application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java

@ -135,6 +135,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
}
public Path getWidgetBundlesDir() {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
}
public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@ -207,7 +211,7 @@ public class InstallScripts {
public void loadSystemWidgets() {
log.info("Loading system widgets");
Map<Path, JsonNode> widgetsBundlesMap = new HashMap<>();
Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(
path -> {

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

@ -28,8 +28,10 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
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.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
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;
@ -42,6 +44,9 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@ -67,6 +72,7 @@ public class SystemPatchApplier {
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService;
@PostConstruct
@ -100,8 +106,11 @@ public class SystemPatchApplier {
updateSqlViews();
log.info("Updated sql database views");
int updated = updateWidgetTypes();
log.info("Updated {} widget types", updated);
WidgetTypeStats widgetStats = updateWidgetTypes();
log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated());
int updatedBundles = updateWidgetBundles();
log.info("System widget bundles: {} updated", updatedBundles);
int createdImages = createMissingSystemImages();
log.info("Created {} new system images", createdImages);
@ -171,20 +180,24 @@ public class SystemPatchApplier {
}
}
private int updateWidgetTypes() {
private WidgetTypeStats updateWidgetTypes() {
AtomicInteger created = new AtomicInteger();
AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir);
return 0;
return new WidgetTypeStats(0, 0);
}
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(
path -> {
try {
if (updateWidgetTypeFromFile(path)) {
WidgetTypeChange change = updateWidgetTypeFromFile(path);
if (change == WidgetTypeChange.CREATED) {
created.incrementAndGet();
} else if (change == WidgetTypeChange.UPDATED) {
updated.incrementAndGet();
}
} catch (Exception e) {
@ -195,18 +208,26 @@ public class SystemPatchApplier {
);
}
return updated.get();
return new WidgetTypeStats(created.get(), updated.get());
}
private boolean updateWidgetTypeFromFile(Path filePath) {
private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
return saveOrUpdateSystemWidgetType(fileWidgetType);
}
private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) {
String fqn = fileWidgetType.getFqn();
if (fqn == null || fqn.isBlank()) {
throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName());
}
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) {
// We expect only update here, so it's probably never happening, but for test purpose leave it like this:
throw new RuntimeException("Widget type not found: " + fqn);
widgetTypeService.saveWidgetType(fileWidgetType);
log.trace("Created widget type: {}", fqn);
return WidgetTypeChange.CREATED;
}
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription());
@ -214,11 +235,92 @@ public class SystemPatchApplier {
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn);
return true;
return WidgetTypeChange.UPDATED;
}
log.trace("Widget type unchanged: {}", fqn);
return false;
return WidgetTypeChange.UNCHANGED;
}
private int updateWidgetBundles() {
AtomicInteger updated = new AtomicInteger();
Path widgetBundlesDir = installScripts.getWidgetBundlesDir();
if (!Files.exists(widgetBundlesDir)) {
log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir);
return 0;
}
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(path -> {
try {
if (processWidgetBundleFile(path)) {
updated.incrementAndGet();
}
} catch (Exception e) {
log.error("Unable to process widgets bundle from json: [{}]", path);
throw new RuntimeException("Unable to process widgets bundle from json", e);
}
});
}
return updated.get();
}
private boolean processWidgetBundleFile(Path filePath) {
JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile());
if (bundleJson == null || !bundleJson.has("widgetsBundle")) {
throw new RuntimeException("Invalid widgets bundle json: " + filePath);
}
WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class);
String alias = fileBundle.getAlias();
if (alias == null || alias.isBlank()) {
throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath);
}
WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
if (existingBundle == null) {
log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias);
return false;
}
if (bundleJson.has("widgetTypes")) {
throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " +
"place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath);
}
List<String> fileWidgetFqns = new ArrayList<>();
if (bundleJson.has("widgetTypeFqns")) {
bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText()));
}
boolean changed = false;
if (isWidgetsBundleChanged(existingBundle, fileBundle)) {
existingBundle.setTitle(fileBundle.getTitle());
existingBundle.setDescription(fileBundle.getDescription());
existingBundle.setImage(fileBundle.getImage());
existingBundle.setOrder(fileBundle.getOrder());
existingBundle.setScada(fileBundle.isScada());
widgetsBundleService.saveWidgetsBundle(existingBundle);
log.trace("Updated widgets bundle metadata: {}", alias);
changed = true;
}
List<String> existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
LinkedHashSet<String> mergedFqns = new LinkedHashSet<>(existingFqns);
if (mergedFqns.addAll(fileWidgetFqns)) {
widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns));
log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias);
changed = true;
}
return changed;
}
private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) {
return !Objects.equals(existing.getTitle(), file.getTitle())
|| !Objects.equals(existing.getDescription(), file.getDescription())
|| !Objects.equals(existing.getImage(), file.getImage())
|| !Objects.equals(existing.getOrder(), file.getOrder())
|| existing.isScada() != file.isScada();
}
private int createMissingSystemImages() {
@ -349,4 +451,8 @@ 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 }
}

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

@ -31,9 +31,12 @@ import org.springframework.test.util.ReflectionTestUtils;
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.dao.resource.ImageService;
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.system.SystemPatchApplier;
@ -41,6 +44,7 @@ import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
@ -81,6 +85,9 @@ public class SystemPatchApplierTest {
@Mock
private WidgetTypeService widgetTypeService;
@Mock
private WidgetsBundleService widgetsBundleService;
@Mock
private ImageService imageService;
@ -155,19 +162,72 @@ public class SystemPatchApplierTest {
}
@Test
void whenWidgetNotFound_thenThrowException() throws Exception {
void whenWidgetNotFound_thenCreateNewWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget");
String json = JacksonUtil.toString(testWidget);
WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget");
String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
Files.writeString(widgetTypesDir.resolve("new_widget.json"), json);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null);
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn())));
}
@Test
void whenFqnIsBlank_thenThrowException() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget");
String json = JacksonUtil.toString(brokenWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("broken.json"), json);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
verify(widgetTypeService, never()).saveWidgetType(any());
}
@Test
void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New");
Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget));
WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name");
Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget));
WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same");
Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget));
WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name");
existingChanged.setId(new WidgetTypeId(UUID.randomUUID()));
WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same");
existingSame.setId(new WidgetTypeId(UUID.randomUUID()));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService, times(2)).saveWidgetType(any());
}
@Test
@ -189,9 +249,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2
));
@ -214,9 +276,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
}
@ -237,9 +301,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(0, updated);
assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService, never()).saveWidgetType(any());
}
@ -339,8 +405,8 @@ public class SystemPatchApplierTest {
// Simulate work while holding lock
Thread.sleep(100);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(updated != null && updated > 0);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@ -360,8 +426,8 @@ public class SystemPatchApplierTest {
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) {
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(updated != null && updated > 0);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
@ -560,6 +626,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -607,6 +674,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -834,6 +902,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
@ -854,4 +923,174 @@ public class SystemPatchApplierTest {
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
}
// --- updateWidgetBundles tests ---
@Test
void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() {
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService).updateWidgetsBundleWidgetFqns(
eq(TenantId.SYS_TENANT_ID),
eq(existingBundle.getId()),
argThat(fqns -> fqns.size() == 3
&& fqns.get(0).equals("line_chart")
&& fqns.get(1).equals("bar_chart")
&& fqns.get(2).equals("new_chart"))
);
}
@Test
void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleMetadataChanged_thenUpdateBundle() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Old Title");
existingBundle.setDescription("old");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService).saveWidgetsBundle(argThat(b ->
"New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20
));
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleAliasIsBlank_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"),
"{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypes\":[" +
"{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" +
"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetTypeService, never()).saveWidgetType(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
private WidgetsBundle createTestBundle(String alias, String title) {
WidgetsBundle bundle = new WidgetsBundle();
bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
bundle.setAlias(alias);
bundle.setTitle(title);
bundle.setTenantId(TenantId.SYS_TENANT_ID);
return bundle;
}
}

Loading…
Cancel
Save