fix(manager-api): make JSON collections type-safe

This commit is contained in:
Tyke Chen
2026-07-21 14:43:25 +08:00
parent bdb2538239
commit d760465408
16 changed files with 412 additions and 48 deletions
@@ -0,0 +1,57 @@
package xiaozhi.common.redis;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.serializer.RedisSerializer;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class RedisSerializationTest {
private final RedisSerializer<Object> serializer = RedisSerializer.json();
@Test
void serverConfigRoundTripRestoresStringKeyedMaps() {
Map<String, Object> features = new HashMap<>();
features.put("addressBook", Map.of("enabled", true));
Map<String, Object> config = new HashMap<>();
config.put("features", features);
Object restored = roundTrip(config);
Map<String, Object> restoredConfig = JsonUtils.toStringObjectMap(restored);
Map<String, Object> restoredFeatures = JsonUtils.toStringObjectMap(restoredConfig.get("features"));
Map<String, Object> addressBook = JsonUtils.toStringObjectMap(restoredFeatures.get("addressBook"));
assertEquals(true, addressBook.get("enabled"));
}
@Test
void dictionaryListRoundTripRestoresDtoElements() {
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
Object restored = roundTrip(new ArrayList<>(List.of(item)));
List<SysDictDataItem> restoredItems = JsonUtils.toList(restored, SysDictDataItem.class);
assertInstanceOf(SysDictDataItem.class, restoredItems.get(0));
assertEquals("enabled", restoredItems.get(0).getName());
assertEquals("1", restoredItems.get(0).getKey());
}
private Object roundTrip(Object value) {
byte[] bytes = serializer.serialize(value);
assertNotNull(bytes);
Object restored = serializer.deserialize(bytes);
assertNotNull(restored);
return restored;
}
}
@@ -0,0 +1,77 @@
package xiaozhi.common.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonUtilsTest {
@Test
void parsesTypedMapsAndPreservesNestedCollectionShapes() {
Map<String, Object> map = JsonUtils.parseMap(
"{\"enabled\":true,\"nested\":{\"items\":[{\"name\":\"tool\"}]}}");
Map<?, ?> nested = assertInstanceOf(Map.class, map.get("nested"));
List<?> items = assertInstanceOf(List.class, nested.get("items"));
Map<?, ?> item = assertInstanceOf(Map.class, items.get(0));
assertEquals(true, map.get("enabled"));
assertEquals("tool", item.get("name"));
List<Map<String, Object>> maps = JsonUtils.parseMapList(
"[{\"name\":\"first\",\"values\":[1,2]},{\"name\":\"second\"}]");
assertEquals("first", maps.get(0).get("name"));
assertEquals(List.of(1, 2), maps.get(0).get("values"));
assertEquals("second", maps.get(1).get("name"));
}
@Test
void checkedConvertersAreShallowMutableCopies() {
List<Object> nested = new ArrayList<>(List.of(Map.of("name", "tool")));
Map<String, Object> source = new LinkedHashMap<>();
source.put("nested", nested);
Map<String, Object> map = JsonUtils.toStringObjectMap(source);
List<Map<String, Object>> maps = JsonUtils.toStringObjectMapList(List.of(source));
List<String> strings = JsonUtils.toList(Arrays.asList("a", null), String.class);
assertSame(nested, map.get("nested"));
assertSame(nested, maps.get(0).get("nested"));
map.put("enabled", true);
maps.add(Map.of("name", "second"));
strings.add("b");
assertEquals(true, map.get("enabled"));
assertEquals(2, maps.size());
assertEquals(Arrays.asList("a", null, "b"), strings);
}
@Test
void preservesNullInputs() {
assertNull(JsonUtils.parseMap(""));
assertNull(JsonUtils.parseMapList(null));
assertNull(JsonUtils.toStringObjectMap(null));
assertNull(JsonUtils.toStringObjectMapList(null));
assertNull(JsonUtils.toList(null, String.class));
}
@Test
void rejectsUnexpectedJsonShapesAndRuntimeTypes() {
assertThrows(RuntimeException.class, () -> JsonUtils.parseMap("[]"));
assertThrows(RuntimeException.class, () -> JsonUtils.parseMapList("{}"));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(List.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(Map.of(1, "value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(List.of("value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(Map.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(List.of(1), String.class));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(Map.of(), String.class));
}
}
@@ -0,0 +1,96 @@
package xiaozhi.modules.config.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
class ConfigServiceImplTest {
@Test
void cachedServerConfigIsCheckedAsAStringKeyedMapWithoutChangingNestedValues() {
RedisUtils redisUtils = mock(RedisUtils.class);
Map<String, Object> nested = new HashMap<>();
nested.put("enabled", true);
Map<String, Object> cached = new HashMap<>();
cached.put("features", nested);
when(redisUtils.get(RedisKeys.getServerConfigKey())).thenReturn(cached);
ConfigServiceImpl service = newService(mock(SysParamsService.class), redisUtils);
Map<String, Object> result = service.getConfig(true);
assertNotSame(cached, result);
assertSame(nested, result.get("features"));
assertEquals(true, ((Map<?, ?>) result.get("features")).get("enabled"));
}
@Test
void nestedSystemParametersStillShareAndPopulateTheSameConfigBranch() {
SysParamsService sysParamsService = mock(SysParamsService.class);
SysParamsDTO enabled = parameter("server.features.enabled", "true", "boolean");
SysParamsDTO labels = parameter("server.features.labels", "first;second", "array");
when(sysParamsService.list(anyMap())).thenReturn(List.of(enabled, labels));
ConfigServiceImpl service = newService(sysParamsService, mock(RedisUtils.class));
Map<String, Object> config = new HashMap<>();
Object returned = ReflectionTestUtils.invokeMethod(service, "buildConfig", config);
assertSame(config, returned);
Map<?, ?> server = assertInstanceOf(Map.class, config.get("server"));
Map<?, ?> features = assertInstanceOf(Map.class, server.get("features"));
assertEquals(true, features.get("enabled"));
assertEquals(List.of("first", "second"), features.get("labels"));
}
private static SysParamsDTO parameter(String code, String value, String type) {
SysParamsDTO parameter = new SysParamsDTO();
parameter.setParamCode(code);
parameter.setParamValue(value);
parameter.setValueType(type);
return parameter;
}
private static ConfigServiceImpl newService(SysParamsService sysParamsService, RedisUtils redisUtils) {
return new ConfigServiceImpl(
sysParamsService,
mock(DeviceService.class),
mock(ModelConfigService.class),
mock(AgentService.class),
mock(AgentTemplateService.class),
redisUtils,
mock(TimbreService.class),
mock(AgentPluginMappingService.class),
mock(AgentMcpAccessPointService.class),
mock(AgentContextProviderService.class),
mock(VoiceCloneService.class),
mock(AgentVoicePrintDao.class),
mock(CorrectWordFileService.class));
}
}
@@ -0,0 +1,35 @@
package xiaozhi.modules.sys.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class SysDictDataServiceImplTest {
@Test
void cachedDictionaryItemsAreCheckedAndReturnedAsDtos() {
RedisUtils redisUtils = mock(RedisUtils.class);
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
List<SysDictDataItem> cached = new ArrayList<>(List.of(item));
when(redisUtils.get(RedisKeys.getDictDataByTypeKey("status"))).thenReturn(cached);
SysDictDataServiceImpl service = new SysDictDataServiceImpl(mock(SysUserDao.class), redisUtils);
List<SysDictDataItem> result = service.getDictDataByType("status");
assertNotSame(cached, result);
assertEquals(List.of(item), result);
}
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.sys.service.impl;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.sys.dao.SysParamsDao;
import xiaozhi.modules.sys.redis.SysParamsRedis;
class SysParamsServiceImplTest {
@Test
void disablingAddressBookStillDeletesItsSystemPlugin() {
SysParamsRedis sysParamsRedis = mock(SysParamsRedis.class);
AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class);
SysParamsDao sysParamsDao = mock(SysParamsDao.class);
SysParamsServiceImpl service = new SysParamsServiceImpl(sysParamsRedis, pluginMappingService);
ReflectionTestUtils.setField(service, "baseDao", sysParamsDao);
String currentConfig = "{\"features\":{\"addressBook\":{\"enabled\":true}}}";
String newConfig = "{\"features\":{\"addressBook\":{\"enabled\":false}}}";
when(sysParamsDao.getValueByCode(Constant.SYSTEM_WEB_MENU)).thenReturn(currentConfig);
when(sysParamsDao.updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig)).thenReturn(1);
service.updateSystemWebMenu(newConfig);
verify(pluginMappingService).deleteByPluginId("SYSTEM_PLUGIN_CALL_DEVICE");
verify(sysParamsDao).updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig);
verify(sysParamsRedis).set(Constant.SYSTEM_WEB_MENU, newConfig);
}
}