map;
+
+ private YamlMapBuilder() {
+ this.map = new LinkedHashMap<>();
+ }
+
+ /**
+ * Creates a new builder instance.
+ *
+ * @return a new YamlMapBuilder
+ */
+ public static YamlMapBuilder create() {
+ return new YamlMapBuilder();
+ }
+
+ /**
+ * Adds a field if the value is not null.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if not null)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotNull(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (value != null) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field if the condition is true.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if condition is true)
+ * @param condition the condition
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIf(String key, Object value, boolean condition) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (condition) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field unconditionally.
+ *
+ * @param key the key (must not be null)
+ * @param value the value
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder put(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ map.put(key, value);
+ return this;
+ }
+
+ /**
+ * Adds a field if the collection is not null and not empty.
+ *
+ * @param key the key (must not be null)
+ * @param collection the collection (only added if not null and not empty)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotEmpty(String key, java.util.Collection> collection) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (collection != null && !collection.isEmpty()) {
+ map.put(key, collection);
+ }
+ return this;
+ }
+
+ /**
+ * Merges all fields from a Map into this builder.
+ * This is useful for inheritance where subclasses want to include parent class fields.
+ *
+ * Usage in subclasses:
+ *
+ * return YamlMapBuilder.create()
+ * .mergeObject(super.toYaml(visitedSet))
+ * .putIfNotNull("field", value)
+ * .build();
+ *
+ *
+ * @param objectMap the Map containing fields to merge (may be null, in which case nothing is merged)
+ * @return this builder for chaining
+ */
+ public YamlMapBuilder mergeObject(Map objectMap) {
+ if (objectMap != null) {
+ objectMap.forEach(map::put);
+ }
+ return this;
+ }
+
+ /**
+ * Builds and returns a defensive copy of the map.
+ *
+ * @return a new LinkedHashMap containing the built entries
+ */
+ public Map build() {
+ return new LinkedHashMap<>(map);
+ }
+ }
+
+ /**
+ * Converts a Set to a sorted List for YAML output.
+ *
+ * @param set the set to convert
+ * @return a sorted list, or null if the set is null or empty
+ */
+ public static > List setToSortedList(Set set) {
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Converts a Set to a sorted List using a mapper function.
+ *
+ * @param set the set to convert
+ * @param mapper the mapper function (must not be null)
+ * @return a sorted list, or null if the set is null or empty
+ * @throws NullPointerException if mapper is null
+ */
+ public static > List setToSortedList(Set set, Function mapper) {
+ if (mapper == null) {
+ throw new NullPointerException("Mapper function must not be null");
+ }
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().map(mapper).sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Creates an empty {@link Set} suitable for {@link YamlConvertible#toYaml(Set, int)} visited tracking.
+ * The set uses reference identity ({@link IdentityHashMap}), not {@link Object#equals(Object) equals},
+ * so distinct object graphs are not mistaken for cycles when types override equality.
+ *
+ * @return a new modifiable identity-based set
+ */
+ public static Set newIdentityVisitedSet() {
+ return Collections.newSetFromMap(new IdentityHashMap<>());
+ }
+
+ /**
+ * Converts a value to YAML-compatible format, handling nested structures.
+ * For objects that implement YamlConvertible, circular reference detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection (may be null)
+ * @return the converted value
+ */
+ public static Object toYamlValue(Object value, Set visited) {
+ return toYamlValue(value, visited, Integer.MAX_VALUE);
+ }
+
+ /**
+ * Converts a value to YAML-compatible format with depth limiting to prevent StackOverflowError.
+ * For objects that implement YamlConvertible, circular reference detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection (may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError from deep nesting)
+ * @return the converted value, or a placeholder if max depth exceeded
+ */
+ public static Object toYamlValue(Object value, Set visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return "";
+ }
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof YamlConvertible) {
+ // For YamlConvertible, get the Map and then process it as a Map to ensure sorting
+ // Pass maxDepth - 1 to the toYaml method to continue depth limiting
+ Map result = ((YamlConvertible) value).toYaml(visited, maxDepth - 1);
+ // Process the result as a Map to ensure it's sorted (this handles both sorting and recursive processing)
+ return toYamlValue(result, visited, maxDepth - 1);
+ }
+ if (value instanceof List) {
+ return ((List>) value).stream()
+ .map(item -> toYamlValue(item, visited, maxDepth - 1))
+ .collect(Collectors.toList());
+ }
+ if (value instanceof Map) {
+ Map, ?> inputMap = (Map, ?>) value;
+ Map result = new LinkedHashMap<>();
+
+ if (!inputMap.isEmpty()) {
+ // Sort entries alphabetically by key string representation
+ inputMap.entrySet().stream()
+ .sorted((e1, e2) -> String.valueOf(e1.getKey()).compareTo(String.valueOf(e2.getKey())))
+ .forEach(entry ->
+ result.put(String.valueOf(entry.getKey()), toYamlValue(entry.getValue(), visited, maxDepth - 1)));
+ }
+ return result;
+ }
+ return value;
+ }
+
+
+ /**
+ * Formats a value as YAML using SnakeYaml.
+ * This is a convenience method that delegates to SnakeYaml.
+ *
+ * @param value the value to format
+ * @return YAML string representation
+ */
+ public static String format(Object value) {
+ return YAML_INSTANCE.dump(value);
+ }
+
+ /**
+ * Creates a circular reference marker map.
+ *
+ * @return a map indicating a circular reference
+ */
+ public static Map circularRef() {
+ return YamlMapBuilder.create()
+ .put("$ref", "circular")
+ .build();
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/ParameterTest.java b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
new file mode 100644
index 0000000000..2ed12d0c68
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.api;
+
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Parameter} YAML and accessors.
+ */
+public class ParameterTest {
+
+ @Test
+ public void testToYamlMaxDepthZeroIncludesExpectedKeys() {
+ Parameter p = new Parameter();
+ p.setId("pid");
+ p.setType("string");
+ p.setMultivalued(true);
+ p.setDefaultValue("def");
+ Map y = p.toYaml(null, 0);
+ assertEquals("pid", y.get("id"));
+ assertEquals("string", y.get("type"));
+ assertTrue(y.containsKey("multivalued"));
+ assertEquals("", y.get("defaultValue"));
+ }
+
+ @Test
+ public void testToYamlMaxDepthZeroAddsDefaultValueTruncationMarkerEvenWhenUnset() {
+ Parameter p = new Parameter();
+ p.setMultivalued(false);
+ Map y = p.toYaml(null, 0);
+ assertFalse(y.containsKey("id"));
+ assertFalse(y.containsKey("type"));
+ assertFalse(y.containsKey("multivalued"));
+ assertEquals("", y.get("defaultValue"));
+ }
+
+ @Test
+ public void testToYamlNormalPath() {
+ Parameter p = new Parameter("id1", "number", false);
+ p.setDefaultValue(42);
+ Map y = p.toYaml(null, 10);
+ assertEquals("id1", y.get("id"));
+ assertEquals("number", y.get("type"));
+ assertFalse(y.containsKey("multivalued"));
+ assertEquals(42, y.get("defaultValue"));
+ }
+
+ @Test
+ public void testToYamlMultivaluedTrueAddsFlag() {
+ Parameter p = new Parameter("i", "t", true);
+ Map y = p.toYaml(null, 10);
+ assertEquals(Boolean.TRUE, y.get("multivalued"));
+ }
+
+ @Test
+ public void testToStringIsNonEmptyYaml() {
+ Parameter p = new Parameter("x", "boolean", false);
+ String s = p.toString();
+ assertNotNull(s);
+ assertTrue(s.length() > 0);
+ assertTrue(s.contains("x"));
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
new file mode 100644
index 0000000000..35e6edf250
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.api.conditions;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Condition} behavior (parameters, YAML, deep copy).
+ */
+public class ConditionTest {
+
+ @Test
+ public void testSetParameterValuesNullReplacesWithEmptyMap() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ c.getParameterValues().put("k", "v");
+ c.setParameterValues(null);
+ assertNotNull(c.getParameterValues());
+ assertTrue(c.getParameterValues().isEmpty());
+ assertFalse(c.containsParameter("k"));
+ assertNull(c.getParameter("k"));
+ }
+
+ @Test
+ public void testSetParameterAfterClearingParameterValues() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ c.setParameterValues(null);
+ c.setParameter("x", 1);
+ assertEquals(Integer.valueOf(1), c.getParameter("x"));
+ assertTrue(c.containsParameter("x"));
+ }
+
+ @Test
+ public void testToYamlMaxDepthZeroUsesPlaceholder() {
+ Condition c = new Condition();
+ c.setConditionTypeId("myType");
+ c.getParameterValues().put("p", "v");
+ Map y = c.toYaml(null, 0);
+ assertEquals("myType", y.get("type"));
+ assertEquals("", y.get("parameterValues"));
+ }
+
+ @Test
+ public void testToYamlMaxDepthZeroDefaultTypeWhenIdMissing() {
+ Condition c = new Condition();
+ Map y = c.toYaml(null, 0);
+ assertEquals("Condition", y.get("type"));
+ }
+
+ @Test
+ public void testToYamlWhenAlreadyVisitedReturnsCircularMarker() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ visited.add(c);
+ Map y = c.toYaml(visited, 5);
+ assertEquals("circular", y.get("$ref"));
+ }
+
+ @Test
+ public void testToYamlOmitsParameterValuesWhenEmpty() {
+ Condition c = new Condition();
+ c.setConditionTypeId("onlyType");
+ Map y = c.toYaml(null, 10);
+ assertFalse(y.containsKey("parameterValues"));
+ }
+
+ @Test
+ public void testToStringUsesYamlFormat() {
+ Condition c = new Condition();
+ c.setConditionTypeId("ctype");
+ String s = c.toString();
+ assertNotNull(s);
+ assertTrue(s.contains("ctype"));
+ }
+
+ @Test
+ public void testDeepCopyPreservesConditionTypeIdOnly() {
+ Condition c = new Condition();
+ c.setConditionTypeId("idOnly");
+ Condition copy = c.deepCopy();
+ assertNotSame(c, copy);
+ assertEquals("idOnly", copy.getConditionTypeId());
+ assertNull(copy.getConditionType());
+ }
+
+ @Test
+ public void testDeepCopyPreservesConditionTypeReference() {
+ ConditionType ct = new ConditionType(new Metadata("meta-ct"));
+ ct.setItemId("evaluatorType");
+ Condition c = new Condition(ct);
+ Condition copy = c.deepCopy();
+ assertSame(ct, copy.getConditionType());
+ assertEquals("evaluatorType", copy.getConditionTypeId());
+ }
+
+ @Test
+ public void testDeepCopyNestedConditionInSetBecomesArrayList() {
+ Condition inner = new Condition();
+ inner.setConditionTypeId("inner");
+ Condition outer = new Condition();
+ outer.setConditionTypeId("outer");
+ Set nested = new LinkedHashSet<>();
+ nested.add(inner);
+ outer.getParameterValues().put("conds", nested);
+
+ Condition copy = outer.deepCopy();
+ Object copiedVal = copy.getParameterValues().get("conds");
+ assertTrue(copiedVal instanceof ArrayList);
+ @SuppressWarnings("unchecked")
+ Collection col = (Collection) copiedVal;
+ assertEquals(1, col.size());
+ Condition copyInner = col.iterator().next();
+ assertNotSame(inner, copyInner);
+ assertEquals("inner", copyInner.getConditionTypeId());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testDeepCopyRejectsSelfReferenceInParameterMap() {
+ Condition c = new Condition();
+ c.setConditionTypeId("self");
+ c.getParameterValues().put("me", c);
+ c.deepCopy();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testDeepCopyRejectsSelfInSingletonCollection() {
+ Condition c = new Condition();
+ c.setConditionTypeId("self");
+ c.getParameterValues().put("list", Collections.singletonList(c));
+ c.deepCopy();
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ Condition a = new Condition();
+ a.setConditionTypeId("t");
+ a.getParameterValues().put("k", 1);
+ Condition b = new Condition();
+ b.setConditionTypeId("t");
+ b.getParameterValues().put("k", 1);
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
new file mode 100644
index 0000000000..af41505121
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
@@ -0,0 +1,729 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.api.utils;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.rules.Rule;
+import org.junit.Test;
+
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for YamlUtils fluent API.
+ * Tests focus on our fluent API, not SnakeYaml's implementation.
+ */
+public class YamlUtilsTest {
+
+ @Test
+ public void testYamlMapBuilderCreate() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ assertNotNull("Builder should be created", builder);
+ }
+
+ @Test
+ public void testYamlMapBuilderPut() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .put("key1", "value1")
+ .put("key2", 42)
+ .build();
+ assertEquals("First value should be set", "value1", map.get("key1"));
+ assertEquals("Second value should be set", 42, map.get("key2"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotNull() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("key1", "value1")
+ .putIfNotNull("key2", null)
+ .putIfNotNull("key3", "value3")
+ .build();
+ assertEquals("Non-null value should be set", "value1", map.get("key1"));
+ assertFalse("Null value should not be set", map.containsKey("key2"));
+ assertEquals("Another non-null value should be set", "value3", map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIf() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .putIf("key1", "value1", true)
+ .putIf("key2", "value2", false)
+ .putIf("key3", "value3", true)
+ .build();
+ assertEquals("Value with true condition should be set", "value1", map.get("key1"));
+ assertFalse("Value with false condition should not be set", map.containsKey("key2"));
+ assertEquals("Another value with true condition should be set", "value3", map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotEmpty() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotEmpty("key1", Arrays.asList("a", "b"))
+ .putIfNotEmpty("key2", Collections.emptyList())
+ .putIfNotEmpty("key3", null)
+ .putIfNotEmpty("key4", Arrays.asList("c"))
+ .build();
+ assertTrue("Non-empty collection should be set", map.containsKey("key1"));
+ assertFalse("Empty collection should not be set", map.containsKey("key2"));
+ assertFalse("Null collection should not be set", map.containsKey("key3"));
+ assertTrue("Another non-empty collection should be set", map.containsKey("key4"));
+ }
+
+ @Test
+ public void testYamlMapBuilderChaining() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .put("a", 1)
+ .putIfNotNull("b", "value")
+ .putIf("c", 3, true)
+ .putIfNotEmpty("d", Arrays.asList(1, 2))
+ .build();
+ assertEquals("All valid entries should be added", 4, map.size());
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyThrowsException() {
+ try {
+ YamlUtils.YamlMapBuilder.create().put(null, "value");
+ fail("Null key should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotNull() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotNull(null, "value");
+ fail("Null key in putIfNotNull should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIf() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIf(null, "value", true);
+ fail("Null key in putIf should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotEmpty() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotEmpty(null, Arrays.asList(1));
+ fail("Null key in putIfNotEmpty should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderBuildReturnsNewMap() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ builder.put("key", "value");
+ Map map1 = builder.build();
+ Map map2 = builder.build();
+ assertNotSame("Each build() should return a new map", map1, map2);
+ assertEquals("Both maps should have same content", map1, map2);
+ }
+
+ @Test
+ public void testSetToSortedList() {
+ Set set = new LinkedHashSet<>(Arrays.asList("zebra", "apple", "banana"));
+ List result = YamlUtils.setToSortedList(set);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list", Arrays.asList("apple", "banana", "zebra"), result);
+ }
+
+ @Test
+ public void testSetToSortedListNull() {
+ List result = YamlUtils.setToSortedList((Set) null);
+ assertNull("Null set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListEmpty() {
+ List result = YamlUtils.setToSortedList(Collections.emptySet());
+ assertNull("Empty set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapper() {
+ Set set = new LinkedHashSet<>(Arrays.asList(3, 1, 2));
+ List result = YamlUtils.setToSortedList(set, String::valueOf);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list using mapper", Arrays.asList("1", "2", "3"), result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNull() {
+ try {
+ YamlUtils.setToSortedList(Collections.singleton(1), null);
+ fail("Null mapper should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNullSet() {
+ List result = YamlUtils.setToSortedList(null, String::valueOf);
+ assertNull("Null set should return null even with mapper", result);
+ }
+
+ @Test
+ public void testToYamlValueWithYamlConvertible() {
+ YamlUtils.YamlConvertible convertible = (visited, maxDepth) -> {
+ Map map = new LinkedHashMap<>();
+ map.put("test", "value");
+ return map;
+ };
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(convertible, visited);
+ assertTrue("YamlConvertible should be converted to Map", result instanceof Map);
+ Map, ?> map = (Map, ?>) result;
+ assertEquals("Converted map should contain test value", "value", map.get("test"));
+ }
+
+ @Test
+ public void testToYamlValueWithList() {
+ List list = Arrays.asList("a", "b", "c");
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(list, visited);
+ assertTrue("List should remain a List", result instanceof List);
+ assertEquals("List should be unchanged", list, result);
+ }
+
+ @Test
+ public void testToYamlValueWithMap() {
+ Map map = new LinkedHashMap<>();
+ map.put("key", "value");
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(map, visited);
+ assertTrue("Map should remain a Map", result instanceof Map);
+ assertEquals("Map should contain key-value", "value", ((Map, ?>) result).get("key"));
+ }
+
+ @Test
+ public void testToYamlValueWithNull() {
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(null, visited);
+ assertNull("Null should return null", result);
+ }
+
+ @Test
+ public void testToYamlValueWithPrimitive() {
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(42, visited);
+ assertEquals("Primitive should remain unchanged", 42, result);
+ }
+
+ @Test
+ public void testCircularRef() {
+ Map result = YamlUtils.circularRef();
+ assertNotNull("circularRef should return a map", result);
+ assertEquals("Should contain $ref: circular", "circular", result.get("$ref"));
+ assertEquals("Should have only one entry", 1, result.size());
+ }
+
+ @Test
+ public void testFormatBasic() {
+ // Just verify format() works - we don't test SnakeYaml's output format
+ Map map = new LinkedHashMap<>();
+ map.put("key", "value");
+ String result = YamlUtils.format(map);
+ assertNotNull("Format should return a string", result);
+ assertTrue("Format should contain key", result.contains("key"));
+ assertTrue("Format should contain value", result.contains("value"));
+ }
+
+ // ========== Circular Reference Detection Tests ==========
+
+ @Test
+ public void testRuleInheritanceChainNoCircularRef() {
+ // Test that Rule -> MetadataItem -> Item inheritance chain doesn't produce false circular refs
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ rule.setCondition(condition);
+
+ Map result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertFalse("Should not contain circular reference marker", result.containsKey("$ref"));
+ assertTrue("Should contain condition", result.containsKey("condition"));
+ assertTrue("Should contain itemId from Item parent", result.containsKey("itemId"));
+ assertTrue("Should contain metadata from MetadataItem parent", result.containsKey("metadata"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInCondition() {
+ // Test that a real circular reference (Rule referenced in condition's parameterValues) is detected
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Create a circular reference: condition's parameterValues contains the rule itself
+ condition.getParameterValues().put("referencedRule", rule);
+ rule.setCondition(condition);
+
+ Map result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain condition", result.containsKey("condition"));
+
+ // Check that the circular reference is detected in the condition's parameterValues
+ Map conditionMap = (Map) result.get("condition");
+ assertNotNull("Condition should be serialized", conditionMap);
+ Map paramValues = (Map) conditionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map circularRef = (Map) paramValues.get("referencedRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInActions() {
+ // Test circular reference in actions list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Action action = new Action();
+ action.setActionTypeId("testAction");
+ // Create circular reference: action's parameterValues contains the rule
+ action.getParameterValues().put("triggeringRule", rule);
+ rule.setActions(Collections.singletonList(action));
+
+ Map result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ List> actions = (List>) result.get("actions");
+ assertNotNull("Actions list should exist", actions);
+ assertEquals("Should have one action", 1, actions.size());
+
+ Map actionMap = (Map) actions.get(0);
+ Map paramValues = (Map) actionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map circularRef = (Map) paramValues.get("triggeringRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNestedCircularReference() {
+ // Test nested circular reference: Rule -> Condition -> nested Condition -> Rule
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition outerCondition = new Condition();
+ outerCondition.setConditionTypeId("outerCondition");
+
+ Condition nestedCondition = new Condition();
+ nestedCondition.setConditionTypeId("nestedCondition");
+ // Nested condition references the rule
+ nestedCondition.getParameterValues().put("ruleRef", rule);
+
+ // Outer condition contains nested condition
+ outerCondition.getParameterValues().put("nested", nestedCondition);
+ rule.setCondition(outerCondition);
+
+ Map result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+
+ // Navigate through the nested structure
+ Map conditionMap = (Map) result.get("condition");
+ Map paramValues = (Map) conditionMap.get("parameterValues");
+ Map nestedConditionMap = (Map) paramValues.get("nested");
+ Map nestedParamValues = (Map) nestedConditionMap.get("parameterValues");
+ Map circularRef = (Map) nestedParamValues.get("ruleRef");
+
+ assertNotNull("Circular reference should be detected in nested structure", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testMultipleCircularReferences() {
+ // Test multiple circular references to the same object
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Multiple references to the same rule
+ condition.getParameterValues().put("rule1", rule);
+ condition.getParameterValues().put("rule2", rule);
+ condition.getParameterValues().put("rule3", rule);
+ rule.setCondition(condition);
+
+ Map result = rule.toYaml(null);
+ Map conditionMap = (Map) result.get("condition");
+ Map paramValues = (Map) conditionMap.get("parameterValues");
+
+ // All three references should show circular ref
+ for (String key : Arrays.asList("rule1", "rule2", "rule3")) {
+ Map circularRef = (Map) paramValues.get(key);
+ assertNotNull("Circular reference should be detected for " + key, circularRef);
+ assertEquals("Should contain circular reference marker for " + key, "circular", circularRef.get("$ref"));
+ }
+ }
+
+
+ @Test
+ public void testCircularReferenceInList() {
+ // Test circular reference in a list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // List containing the rule itself
+ condition.getParameterValues().put("ruleList", Arrays.asList(rule, "other", rule));
+ rule.setCondition(condition);
+
+ Map result = rule.toYaml(null);
+ Map conditionMap = (Map) result.get("condition");
+ Map paramValues = (Map) conditionMap.get("parameterValues");
+ List> ruleList = (List>) paramValues.get("ruleList");
+
+ assertNotNull("Rule list should exist", ruleList);
+ assertEquals("List should have 3 elements", 3, ruleList.size());
+
+ // First element should be circular ref
+ Map circularRef1 = (Map) ruleList.get(0);
+ assertEquals("First element should be circular ref", "circular", circularRef1.get("$ref"));
+
+ // Second element should be string
+ assertEquals("Second element should be string", "other", ruleList.get(1));
+
+ // Third element should also be circular ref
+ Map circularRef2 = (Map) ruleList.get(2);
+ assertEquals("Third element should be circular ref", "circular", circularRef2.get("$ref"));
+ }
+
+ @Test
+ public void testCircularReferenceInNestedMap() {
+ // Test circular reference in nested map structure
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Nested map containing the rule
+ Map level2 = new HashMap<>();
+ level2.put("rule", rule);
+ Map level1 = new HashMap<>();
+ level1.put("level2", level2);
+ Map nestedMap = new HashMap<>();
+ nestedMap.put("level1", level1);
+ condition.getParameterValues().put("nested", nestedMap);
+ rule.setCondition(condition);
+
+ Map result = rule.toYaml(null);
+ Map conditionMap = (Map) result.get("condition");
+ Map paramValues = (Map) conditionMap.get("parameterValues");
+ Map nested = (Map) paramValues.get("nested");
+ Map nestedLevel1 = (Map) nested.get("level1");
+ Map nestedLevel2 = (Map) nestedLevel1.get("level2");
+ Map circularRef = (Map) nestedLevel2.get("rule");
+
+ assertNotNull("Circular reference should be detected in nested map", circularRef);
+ assertEquals("Should contain circular reference marker", "circular", circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNoFalseCircularRefInInheritance() {
+ // Test that inheritance chain (Rule -> MetadataItem -> Item) doesn't create false circular refs
+ // This is the main bug we're fixing
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("unavailableConditionType");
+ condition.getParameterValues().put("comparisonOperator", "equals");
+ condition.getParameterValues().put("propertyName", "testProperty");
+ condition.getParameterValues().put("propertyValue", "testValue");
+ rule.setCondition(condition);
+
+ Action action = new Action();
+ action.setActionTypeId("test");
+ rule.setActions(Collections.singletonList(action));
+
+ Map result = rule.toYaml(null);
+
+ // Should NOT contain $ref: circular at the top level
+ assertNotNull("Rule should serialize", result);
+ assertFalse("Should not have false circular reference at top level",
+ result.containsKey("$ref") && "circular".equals(result.get("$ref")));
+
+ // Should contain all expected fields from inheritance chain
+ assertTrue("Should contain itemId from Item", result.containsKey("itemId"));
+ assertTrue("Should contain itemType from Item", result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule", result.get("itemType"));
+ assertTrue("Should contain metadata from MetadataItem", result.containsKey("metadata"));
+ assertTrue("Should contain condition", result.containsKey("condition"));
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ // Verify condition structure
+ Map conditionMap = (Map) result.get("condition");
+ assertNotNull("Condition should be present", conditionMap);
+ assertEquals("Condition should have correct type", "unavailableConditionType", conditionMap.get("type"));
+
+ // Verify actions structure
+ List> actions = (List>) result.get("actions");
+ assertNotNull("Actions should be present", actions);
+ assertEquals("Should have one action", 1, actions.size());
+ }
+
+ @Test
+ public void testItemTypeIsAlwaysIncluded() {
+ // Test that itemType is always included in YAML output, even if null
+ // This reflects the actual state of the object
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map result = rule.toYaml(null);
+
+ // itemType should always be present in output (set in Item constructor for Rule)
+ assertTrue("itemType should be included", result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule", result.get("itemType"));
+
+ // itemId should also always be included
+ assertTrue("itemId should be included", result.containsKey("itemId"));
+ }
+
+ @Test
+ public void testItemIdAndItemTypeIncludedEvenWhenNull() {
+ // Test that itemId and itemType are always included, even when null
+ // This ensures YAML output reflects the actual state of the object
+ Rule rule = new Rule();
+ // Explicitly set itemId and itemType to null to test null handling
+ rule.setItemId(null);
+ rule.setItemType(null);
+
+ Map result = rule.toYaml(null);
+
+ // Both should be included even if null
+ assertTrue("itemId should be included even when null", result.containsKey("itemId"));
+ assertNull("itemId should be null", result.get("itemId"));
+
+ assertTrue("itemType should be included even when null", result.containsKey("itemType"));
+ assertNull("itemType should be null", result.get("itemType"));
+ }
+
+ @Test
+ public void testItemIdFromMetadata() {
+ // Test that itemId is set from metadata and included in YAML
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-rule-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map result = rule.toYaml(null);
+
+ // itemId should be set from metadata.getId()
+ assertTrue("itemId should be included when set from metadata", result.containsKey("itemId"));
+ assertEquals("itemId should match metadata id", "test-rule-id", result.get("itemId"));
+ }
+
+ @Test
+ public void testVisitedSetIsSharedCorrectly() {
+ // Test that visited set is properly shared across nested calls
+ Rule rule1 = new Rule();
+ rule1.setItemId("rule1");
+ rule1.setMetadata(new Metadata("rule1"));
+
+ Rule rule2 = new Rule();
+ rule2.setItemId("rule2");
+ rule2.setMetadata(new Metadata("rule2"));
+
+ // rule1 references rule2, rule2 references rule1 (mutual circular reference)
+ Condition condition1 = new Condition();
+ condition1.setConditionTypeId("test");
+ condition1.getParameterValues().put("otherRule", rule2);
+ rule1.setCondition(condition1);
+
+ Condition condition2 = new Condition();
+ condition2.setConditionTypeId("test");
+ condition2.getParameterValues().put("otherRule", rule1);
+ rule2.setCondition(condition2);
+
+ // Serialize rule1 - should detect circular ref when it encounters rule2 which references rule1
+ Map result1 = rule1.toYaml(null);
+ assertNotNull("Rule1 should serialize", result1);
+
+ Map conditionMap1 = (Map) result1.get("condition");
+ Map paramValues1 = (Map) conditionMap1.get("parameterValues");
+ Map rule2Ref = (Map) paramValues1.get("otherRule");
+
+ // rule2 should be serialized, but when it tries to reference rule1, it should detect circular ref
+ assertNotNull("Rule2 reference should exist", rule2Ref);
+ // rule2 itself should be fully serialized (not circular), but its condition's otherRule should be circular
+ Map conditionMap2 = (Map) rule2Ref.get("condition");
+ assertNotNull("Rule2's condition should exist", conditionMap2);
+ Map paramValues2 = (Map) conditionMap2.get("parameterValues");
+ Map rule1CircularRef = (Map) paramValues2.get("otherRule");
+ assertNotNull("Circular reference to rule1 should be detected", rule1CircularRef);
+ assertEquals("Should contain circular reference marker", "circular", rule1CircularRef.get("$ref"));
+ }
+
+ @Test
+ public void testConditionDeepCopyCopiesNestedConditions() {
+ Condition inner = new Condition();
+ inner.setConditionTypeId("inner");
+ Condition outer = new Condition();
+ outer.setConditionTypeId("outer");
+ outer.getParameterValues().put("c", inner);
+
+ Condition copy = outer.deepCopy();
+ assertNotSame(outer, copy);
+ Condition copyInner = (Condition) copy.getParameterValues().get("c");
+ assertNotSame(inner, copyInner);
+ assertEquals("inner", copyInner.getConditionTypeId());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConditionDeepCopyRejectsCycle() {
+ Condition a = new Condition();
+ a.setConditionTypeId("a");
+ Condition b = new Condition();
+ b.setConditionTypeId("b");
+ a.getParameterValues().put("child", b);
+ b.getParameterValues().put("child", a);
+ a.deepCopy();
+ }
+
+ @Test
+ public void testNewIdentityVisitedSetUsesReferenceIdentity() {
+ Set visited = YamlUtils.newIdentityVisitedSet();
+ String a = new String("x");
+ String b = new String("x");
+ assertTrue(visited.add(a));
+ assertTrue(visited.add(b));
+ assertEquals("equal strings are distinct objects for identity set", 2, visited.size());
+ }
+
+ @Test
+ public void testYamlMapBuilderMergeObjectNullIsNoOp() {
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .put("k", "v")
+ .mergeObject(null)
+ .build();
+ assertEquals(1, map.size());
+ assertEquals("v", map.get("k"));
+ }
+
+ @Test
+ public void testYamlMapBuilderMergeObjectCopiesEntries() {
+ Map extra = new LinkedHashMap<>();
+ extra.put("a", 1);
+ extra.put("b", 2);
+ Map map = YamlUtils.YamlMapBuilder.create()
+ .put("z", 0)
+ .mergeObject(extra)
+ .build();
+ assertEquals(3, map.size());
+ assertEquals(Integer.valueOf(0), map.get("z"));
+ assertEquals(Integer.valueOf(1), map.get("a"));
+ assertEquals(Integer.valueOf(2), map.get("b"));
+ }
+
+ @Test
+ public void testToYamlValueMaxDepthZeroReturnsPlaceholder() {
+ assertEquals("", YamlUtils.toYamlValue("anything", null, 0));
+ }
+
+ @Test
+ public void testToYamlValueEmptyMapWithDepth() {
+ @SuppressWarnings("unchecked")
+ Map out = (Map) YamlUtils.toYamlValue(Collections.emptyMap(), null, 5);
+ assertNotNull(out);
+ assertTrue(out.isEmpty());
+ }
+
+ @Test
+ public void testToYamlValueSortsMapKeysLexicographically() {
+ Map in = new LinkedHashMap<>();
+ in.put("z", 1);
+ in.put("a", 2);
+ @SuppressWarnings("unchecked")
+ Map out = (Map) YamlUtils.toYamlValue(in, null, 10);
+ assertEquals(Arrays.asList("a", "z"), new ArrayList<>(out.keySet()));
+ }
+
+ @Test
+ public void testToYamlValueNonStringMapKeysBecomeStrings() {
+ Map in = new HashMap<>();
+ in.put(10, "ten");
+ in.put(2, "two");
+ @SuppressWarnings("unchecked")
+ Map out = (Map) YamlUtils.toYamlValue(in, null, 10);
+ assertTrue(out.keySet().stream().allMatch(k -> k instanceof String));
+ assertEquals("two", out.get("2"));
+ assertEquals("ten", out.get("10"));
+ }
+
+ @Test
+ public void testToYamlValueTwoArgDelegatesToUnboundedDepth() {
+ Condition c = new Condition();
+ c.setConditionTypeId("c");
+ c.getParameterValues().put("n", 1);
+ Object result = YamlUtils.toYamlValue(c, YamlUtils.newIdentityVisitedSet());
+ assertTrue(result instanceof Map);
+ }
+
+ @Test
+ public void testYamlConvertibleDefaultToYamlWithVisitedOnly() {
+ YamlUtils.YamlConvertible convertible = new YamlUtils.YamlConvertible() {
+ @Override
+ public Map toYaml(Set visited, int maxDepth) {
+ Map m = new LinkedHashMap<>();
+ m.put("depth", maxDepth);
+ return m;
+ }
+ };
+ Map map = convertible.toYaml(null);
+ assertEquals(Integer.valueOf(20), map.get("depth"));
+ }
+}
diff --git a/bom/pom.xml b/bom/pom.xml
index 69cb5a0518..17b0f37529 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -503,6 +503,26 @@
junit
${junit.version}
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit-jupiter.version}
+
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+
+
+ org.mockito
+ mockito-junit-jupiter
+ ${mockito.version}
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+
diff --git a/itests/pom.xml b/itests/pom.xml
index 0d0ccde8c5..d869a70290 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -32,6 +32,8 @@
elasticsearch
false
itests-opensearch
+
+ true
@@ -43,6 +45,14 @@
pom
import
+
+
+
+
+ org.awaitility
+ awaitility
+ 3.1.6
+
@@ -53,6 +63,16 @@
common
${karaf.version}
test
+
+
+ ch.qos.logback
+ logback-classic
+
+
+ ch.qos.logback
+ logback-core
+
+
@@ -105,7 +125,7 @@
org.apache.servicemix.bundles
org.apache.servicemix.bundles.hamcrest
1.3_1
- runtime
+ test
org.apache.httpcomponents
diff --git a/pom.xml b/pom.xml
index f7e85f4035..e368fba069 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,8 @@
2.3
3.10
2.19.0
+
+ 1.7.36
9.12.2
2.4.0
2.12.7
@@ -102,6 +104,9 @@
4.5.14
4.4.16
4.13.2
+ 5.8.2
+ 4.5.1
+ 4.2.0
2.2.1
4.3.4
1.6.0
diff --git a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
index bc7ab9acf2..d6257c6f09 100644
--- a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
@@ -26,7 +26,7 @@ public class RESTParameter {
private String id;
private String type;
private boolean multivalued = false;
- private String defaultValue = null;
+ private Object defaultValue = null;
public String getId() {
return id;
@@ -52,11 +52,11 @@ public void setMultivalued(boolean multivalued) {
this.multivalued = multivalued;
}
- public String getDefaultValue() {
+ public Object getDefaultValue() {
return defaultValue;
}
- public void setDefaultValue(String defaultValue) {
+ public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
index fe3fe8fb02..8b6e7063c9 100644
--- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
+++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java
@@ -450,7 +450,15 @@ public Set getTrackedConditions(Item source) {
trackedCondition.getConditionType().getParameters().forEach(parameter -> {
try {
if (TRACKED_PARAMETER.equals(parameter.getId())) {
- Arrays.stream(StringUtils.split(parameter.getDefaultValue(), ",")).forEach(trackedParameter -> {
+ // Parameter#getDefaultValue is Object; null must not call toString() (NPE) or be passed to split.
+ Object defaultValue = parameter.getDefaultValue();
+ if (defaultValue == null) {
+ LOGGER.debug(
+ "Skipping tracked parameter mapping: parameter id={} has null defaultValue for condition type {}",
+ parameter.getId(), trackedCondition.getConditionType().getItemId());
+ return;
+ }
+ Arrays.stream(StringUtils.split(defaultValue.toString(), ",")).forEach(trackedParameter -> {
String[] param = StringUtils.split(StringUtils.trim(trackedParameter), ":");
trackedParameters.put(StringUtils.trim(param[1]), trackedCondition.getParameter(StringUtils.trim(param[0])));
});