feat(education): add scalar catalog adapter

This commit is contained in:
2026-07-27 18:39:49 +08:00
parent 0f846fdaf5
commit 478d3d65b7
37 changed files with 3374 additions and 21 deletions

View File

@@ -0,0 +1,272 @@
package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Collections;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* CatalogController HTTP seam test — uses standalone MockMvc with real controller
* wiring to prove route mapping, authentication, query param forwarding, and
* disabled-provider behavior through the HTTP layer.
*
* @author 恭学教育
*/
class CatalogControllerHttpTest {
private MockMvc mockMvc;
private CatalogProvider catalogProvider;
@BeforeEach
void setUp() {
catalogProvider = mock(CatalogProvider.class);
when(catalogProvider.isEnabled()).thenReturn(true);
CatalogService catalogService = new CatalogServiceImpl(catalogProvider);
CatalogController controller = new CatalogController();
try {
var field = CatalogController.class.getDeclaredField("catalogService");
field.setAccessible(true);
field.set(controller, catalogService);
} catch (Exception e) {
throw new RuntimeException(e);
}
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new TestExceptionHandler())
.build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ========== Route mapping + authenticated success ==========
@Test
void shouldReturn200ForRegionsWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listRegions()).thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/regions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray());
}
@Test
void shouldReturn200ForCategoriesWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listCategories("s1", null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/categories")
.param("subjectId", "s1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldReturn200ForSubjectsWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listSubjects("r1", null, null, null, null))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/subjects")
.param("regionId", "r1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldReturn200ForModuleNodesWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listModuleNodes("r1", "m1", "root"))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/module-nodes")
.param("regionId", "r1")
.param("moduleId", "m1")
.param("parentId", "root"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldReturn200ForContentEntriesWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-entries")
.param("regionId", "r1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldReturn200ForContentNodesWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-nodes")
.param("entryId", "e1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldReturn200ForQuestionCollectionsWhenAuthenticated() throws Exception {
setLoginUser(100L);
when(catalogProvider.listQuestionCollections("r1", null, null, null, null))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/question-collections")
.param("regionId", "r1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
// ========== Anonymous → 401 ==========
@Test
void shouldReturn401WhenNoSecurityContext() throws Exception {
mockMvc.perform(get("/education/catalog/regions"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401OnCategoriesWhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/education/catalog/categories")
.param("subjectId", "s1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401OnSubjectsWhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/education/catalog/subjects")
.param("regionId", "r1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401OnContentEntriesWhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/education/catalog/content-entries")
.param("regionId", "r1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
// ========== Query parameter forwarding ==========
@Test
void shouldForwardIncludeHiddenTrue() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", "question_bank", true))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-entries")
.param("regionId", "r1")
.param("entryType", "question_bank")
.param("includeHidden", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldForwardIncludeInactiveTrueAndMarkerType() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", "root", "flat", true, "marker_type"))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-nodes")
.param("entryId", "e1")
.param("parentId", "root")
.param("mode", "flat")
.param("includeInactive", "true")
.param("markerType", "marker_type"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldForwardLimitParameter() throws Exception {
setLoginUser(100L);
when(catalogProvider.listQuestionCollections("r1", "e1", "n1", "exam", 10))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/question-collections")
.param("regionId", "r1")
.param("entryId", "e1")
.param("nodeId", "n1")
.param("collectionType", "exam")
.param("limit", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
// ========== Feature-disabled provider ==========
@Test
void shouldReturnErrorWhenProviderDisabled() throws Exception {
setLoginUser(100L);
when(catalogProvider.isEnabled()).thenReturn(false);
mockMvc.perform(get("/education/catalog/regions"))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(CATALOG_DATA_SOURCE_DISABLED.getCode()));
}
// ========== helpers ==========
private void setLoginUser(Long userId) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(1L);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
/**
* Minimal exception handler for standalone MockMvc.
* Maps ServiceException to proper HTTP status + CommonResult body.
*/
@RestControllerAdvice
static class TestExceptionHandler {
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex,
jakarta.servlet.http.HttpServletResponse response) {
if (ex.getCode() == UNAUTHORIZED.getCode()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return CommonResult.error(UNAUTHORIZED);
}
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
return CommonResult.error(ex.getCode(), ex.getMessage());
}
}
}

View File

@@ -0,0 +1,224 @@
package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.Collections;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
/**
* CatalogController 集成测试。
* 直接调用 Controller 方法并验证认证、参数转发、功能开关行为。
*
* @author 恭学教育
*/
@SpringBootTest(
classes = {
CatalogController.class,
CatalogServiceImpl.class
},
properties = {
"yudao.education.enabled=true",
"yudao.education.version=1.0.0-test"
},
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("unit-test")
class CatalogControllerTest {
@Resource
private CatalogController catalogController;
@MockitoBean
private CatalogProvider catalogProvider;
@BeforeEach
void setUp() {
when(catalogProvider.isEnabled()).thenReturn(true);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ========== 认证成功 ==========
@Test
void shouldReturnRegionsWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listRegions()).thenReturn(Collections.emptyList());
var result = catalogController.listRegions();
assertNotNull(result);
assertEquals(0, result.getCode());
assertNotNull(result.getData());
}
@Test
void shouldReturnCategoriesWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listCategories("s1", null)).thenReturn(Collections.emptyList());
var result = catalogController.listCategories("s1", null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@Test
void shouldReturnSubjectsWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listSubjects("r1", null, null, null, null))
.thenReturn(Collections.emptyList());
var result = catalogController.listSubjects("r1", null, null, null, null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@Test
void shouldReturnModuleNodesWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listModuleNodes("r1", "m1", "root"))
.thenReturn(Collections.emptyList());
var result = catalogController.listModuleNodes("r1", "m1", "root");
assertNotNull(result);
assertEquals(0, result.getCode());
}
@Test
void shouldReturnContentEntriesWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@Test
void shouldReturnContentNodesWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "children", false, null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@Test
void shouldReturnQuestionCollectionsWhenAuthenticated() {
setLoginUser(100L);
when(catalogProvider.listQuestionCollections("r1", null, null, null, null))
.thenReturn(Collections.emptyList());
var result = catalogController.listQuestionCollections("r1", null, null, null, null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
// ========== 未认证拒绝 ==========
@Test
void shouldRejectWhenNotAuthenticated() {
// No security context — should throw UNAUTHORIZED
var ex = assertThrows(ServiceException.class,
() -> catalogController.listRegions());
assertEquals(401, ex.getCode());
}
@Test
void shouldRejectCategoriesWhenNotAuthenticated() {
var ex = assertThrows(ServiceException.class,
() -> catalogController.listCategories(null, null));
assertEquals(401, ex.getCode());
}
@Test
void shouldRejectAllEndpointsWhenNotAuthenticated() {
assertThrows(ServiceException.class, () -> catalogController.listSubjects(null, null, null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listModuleNodes(null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null, false));
assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", false, null));
assertThrows(ServiceException.class, () -> catalogController.listQuestionCollections(null, null, null, null, null));
}
// ========== 参数转发 ==========
@Test
void shouldForwardIncludeHiddenTrue() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, true))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, true);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeHiddenFalse() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeInactiveTrue() {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", null, "flat", true, "marker"))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "flat", true, "marker");
assertEquals(0, result.getCode());
}
// ========== 数据源禁用 ==========
@Test
void shouldReturnErrorWhenProviderDisabled() {
setLoginUser(100L);
when(catalogProvider.isEnabled()).thenReturn(false);
var ex = assertThrows(ServiceException.class,
() -> catalogController.listRegions());
assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode());
}
// ========== 功能开关 ==========
@Test
void shouldRegisterControllerWhenEnabled() {
assertNotNull(catalogController, "Controller should be registered when education.enabled=true");
}
// ========== helpers ==========
private void setLoginUser(Long userId) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(1L);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
}

View File

@@ -0,0 +1,142 @@
package cn.iocoder.yudao.module.education.integration.scalar.config;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
/**
* ScalarAutoConfiguration 启动上下文测试。
* 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景。
*
* @author 恭学教育
*/
class ScalarAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(ScalarAutoConfiguration.class)
.withBean(EducationProperties.class, EducationProperties::new);
// ========== 默认 / 未启用 ==========
@Test
void shouldNotCreateProviderWhenEducationDisabled() {
// Default: yudao.education.enabled=false — no beans created
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(CatalogProvider.class);
});
}
// ========== SCALAR_READ enabled + configured ==========
@Test
void shouldCreateScalarProviderWhenEnabledAndConfigured() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=SCALAR_READ",
"yudao.education.scalar.enabled=true",
"yudao.education.scalar.base-url=http://localhost",
"yudao.education.scalar.token=test-token")
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
assertThat(context).hasSingleBean(ScalarCatalogProvider.class);
CatalogProvider provider = context.getBean(CatalogProvider.class);
assertTrue(provider.isEnabled());
});
}
// ========== SCALAR_READ disabled (但 provider 仍创建) ==========
@Test
void shouldCreateScalarProviderWhenModeScalarReadButScalarDisabled() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=SCALAR_READ")
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
CatalogProvider provider = context.getBean(CatalogProvider.class);
// Scalar disabled by default — provider exists but reports disabled
assertFalse(provider.isEnabled());
});
}
// ========== SCALAR_READ enabled but missing config → startup fails ==========
@Test
void shouldFailStartupWhenScalarEnabledButMissingBaseUrl() {
// Direct constructor test — covered by ScalarCatalogProviderTest
// ApplicationContextRunner catches the factory method exception
// but in Spring 4.x the behavior differs; direct test is more reliable.
cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties bad =
new cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties();
bad.setEnabled(true);
bad.setBaseUrl(null);
bad.setToken("test-token");
var ex = assertThrows(cn.iocoder.yudao.framework.common.exception.ServiceException.class,
() -> new cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider(bad));
assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_SCALAR_NOT_CONFIGURED.getCode(),
ex.getCode());
}
@Test
void shouldFailStartupWhenScalarEnabledButMissingToken() {
cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties bad =
new cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties();
bad.setEnabled(true);
bad.setBaseUrl("http://localhost");
bad.setToken(null);
var ex = assertThrows(cn.iocoder.yudao.framework.common.exception.ServiceException.class,
() -> new cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider(bad));
assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_SCALAR_NOT_CONFIGURED.getCode(),
ex.getCode());
}
// ========== JAVA_READ (unsupported) ==========
@Test
void shouldCreateUnsupportedProviderForJavaReadMode() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
CatalogProvider provider = context.getBean(CatalogProvider.class);
assertThat(provider).isInstanceOf(UnsupportedModeCatalogProvider.class);
assertFalse(provider.isEnabled());
});
}
@Test
void shouldNotCreateScalarProviderWhenJavaReadMode() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.run(context -> {
assertThat(context).doesNotHaveBean(ScalarCatalogProvider.class);
});
}
// ========== Default: SCALAR_READ (matchIfMissing) ==========
@Test
void shouldDefaultToScalarReadWhenNoCatalogMode() {
contextRunner
.withPropertyValues("yudao.education.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
assertThat(context).hasSingleBean(ScalarCatalogProvider.class);
});
}
}

View File

@@ -0,0 +1,262 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
/**
* CatalogServiceImpl 单元测试 — 测试转换逻辑和功能开关。
* 使用领域 DTO不依赖 integration 层。
*
* @author 恭学教育
*/
@ExtendWith(MockitoExtension.class)
class CatalogServiceImplTest {
@Mock
private CatalogProvider catalogProvider;
private CatalogServiceImpl catalogService;
@BeforeEach
void setUp() {
catalogService = new CatalogServiceImpl(catalogProvider);
when(catalogProvider.isEnabled()).thenReturn(true);
}
// ========== Region ==========
@Test
void shouldConvertRegions() {
when(catalogProvider.listRegions()).thenReturn(List.of(
regionDto("r1", "全国", true, 1.0),
regionDto("r2", "华东", true, 2.0)));
List<CatalogRegionRespVO> result = catalogService.listRegions();
assertEquals(2, result.size());
assertEquals("r1", result.get(0).getId());
assertEquals("全国", result.get(0).getName());
assertEquals(1.0, result.get(0).getOrder());
assertTrue(result.get(0).getActive());
assertEquals("r2", result.get(1).getId());
}
@Test
void shouldFilterInactiveRegions() {
when(catalogProvider.listRegions()).thenReturn(List.of(
regionDto("r1", "全国", true, 1.0),
regionDto("r2", "华东", false, 2.0),
regionDto("r3", "华南", null, 3.0)));
List<CatalogRegionRespVO> result = catalogService.listRegions();
assertEquals(2, result.size());
assertEquals("r1", result.get(0).getId());
assertEquals("r3", result.get(1).getId()); // null isActive treated as active
}
@Test
void shouldReturnEmptyListWhenProviderReturnsNull() {
when(catalogProvider.listRegions()).thenReturn(null);
List<CatalogRegionRespVO> result = catalogService.listRegions();
assertNotNull(result);
assertTrue(result.isEmpty());
}
@Test
void shouldReturnEmptyListWhenProviderReturnsEmpty() {
when(catalogProvider.listRegions()).thenReturn(List.of());
List<CatalogRegionRespVO> result = catalogService.listRegions();
assertNotNull(result);
assertTrue(result.isEmpty());
}
// ========== Category ==========
@Test
void shouldConvertCategories() {
when(catalogProvider.listCategories("s1", "n1")).thenReturn(List.of(
categoryDto("c1", "数学", "subject", true, 1.0)));
List<CatalogCategoryRespVO> result = catalogService.listCategories("s1", "n1");
assertEquals(1, result.size());
assertEquals("c1", result.get(0).getId());
assertEquals("数学", result.get(0).getName());
assertEquals("subject", result.get(0).getType());
}
// ========== Subject ==========
@Test
void shouldConvertSubjects() {
when(catalogProvider.listSubjects("r1", null, null, null, null)).thenReturn(List.of(
subjectDto("s1", "数学", "math", "r1", true)));
List<CatalogSubjectRespVO> result = catalogService.listSubjects("r1", null, null, null, null);
assertEquals(1, result.size());
assertEquals("s1", result.get(0).getId());
assertEquals("数学", result.get(0).getName());
assertEquals("math", result.get(0).getType());
assertEquals("r1", result.get(0).getRegionId());
}
// ========== ContentNode ==========
@Test
void shouldConvertContentNodes() {
CatalogContentNodeDTO node = CatalogContentNodeDTO.contentNodeBuilder()
.id("n1").name("节点1").nodeType("chapter")
.entryId("e1").parentId("root")
.depth(1.0).isLeaf(false).isSelectable(true)
.order(1.0).isActive(true)
.build();
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(List.of(node));
List<CatalogContentNodeRespVO> result = catalogService.listContentNodes("e1", null, "children", false, null);
assertEquals(1, result.size());
CatalogContentNodeRespVO vo = result.get(0);
assertEquals("n1", vo.getId());
assertEquals("节点1", vo.getName());
assertEquals("chapter", vo.getNodeType());
assertEquals("e1", vo.getEntryId());
assertEquals("root", vo.getParentId());
assertEquals(1.0, vo.getDepth());
assertFalse(vo.getLeaf());
assertTrue(vo.getSelectable());
}
@Test
void shouldFilterInactiveContentNodesWhenIncludeInactiveIsFalse() {
CatalogContentNodeDTO active = CatalogContentNodeDTO.contentNodeBuilder()
.id("n1").name("active").nodeType("chapter")
.entryId("e1").parentId("root")
.depth(1.0).isLeaf(false).isSelectable(true)
.order(1.0).isActive(true)
.build();
CatalogContentNodeDTO inactive = CatalogContentNodeDTO.contentNodeBuilder()
.id("n2").name("inactive").nodeType("chapter")
.entryId("e1").parentId("root")
.depth(2.0).isLeaf(true).isSelectable(false)
.order(2.0).isActive(false)
.build();
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(List.of(active, inactive));
List<CatalogContentNodeRespVO> result = catalogService.listContentNodes("e1", null, "children", false, null);
assertEquals(1, result.size());
assertEquals("n1", result.get(0).getId());
}
@Test
void shouldIncludeInactiveContentNodesWhenIncludeInactiveIsTrue() {
CatalogContentNodeDTO inactive = CatalogContentNodeDTO.contentNodeBuilder()
.id("n2").name("inactive").nodeType("chapter")
.entryId("e1").parentId("root")
.depth(2.0).isLeaf(true).isSelectable(false)
.order(2.0).isActive(false)
.build();
when(catalogProvider.listContentNodes("e1", null, "children", true, null))
.thenReturn(List.of(inactive));
List<CatalogContentNodeRespVO> result = catalogService.listContentNodes("e1", null, "children", true, null);
assertEquals(1, result.size());
assertEquals("n2", result.get(0).getId());
}
// ========== QuestionCollection ==========
@Test
void shouldConvertQuestionCollections() {
CatalogQuestionCollectionDTO col = CatalogQuestionCollectionDTO.questionCollectionBuilder()
.id("qc1").name("高考真题").collectionType("exam")
.questionCount(100.0).order(1.0).isActive(true)
.build();
when(catalogProvider.listQuestionCollections("r1", null, null, null, null))
.thenReturn(List.of(col));
List<CatalogQuestionCollectionRespVO> result = catalogService.listQuestionCollections("r1", null, null, null, null);
assertEquals(1, result.size());
CatalogQuestionCollectionRespVO vo = result.get(0);
assertEquals("qc1", vo.getId());
assertEquals("高考真题", vo.getName());
assertEquals("exam", vo.getCollectionType());
assertEquals(100L, vo.getQuestionCount());
}
@Test
void shouldHandleNullQuestionCount() {
CatalogQuestionCollectionDTO col = CatalogQuestionCollectionDTO.questionCollectionBuilder()
.id("qc1").name("题库").collectionType("bank")
.questionCount(null).order(1.0).isActive(true)
.build();
when(catalogProvider.listQuestionCollections("r1", null, null, null, null))
.thenReturn(List.of(col));
List<CatalogQuestionCollectionRespVO> result = catalogService.listQuestionCollections("r1", null, null, null, null);
assertEquals(1, result.size());
assertNull(result.get(0).getQuestionCount());
}
// ========== 功能开关 ==========
@Test
void shouldThrowWhenProviderIsDisabled() {
when(catalogProvider.isEnabled()).thenReturn(false);
var ex = assertThrows(ServiceException.class, () -> catalogService.listRegions());
assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode());
}
// ========== helpers ==========
private static CatalogEntityDTO regionDto(String id, String name, Boolean active, Double order) {
return CatalogEntityDTO.builder()
.id(id).name(name).isActive(active).order(order)
.build();
}
private static CatalogEntityDTO categoryDto(String id, String name, String type, Boolean active, Double order) {
return CatalogEntityDTO.builder()
.id(id).name(name).type(type).isActive(active).order(order)
.build();
}
private static CatalogEntityDTO subjectDto(String id, String name, String type, String regionId, Boolean active) {
return CatalogEntityDTO.builder()
.id(id).name(name).type(type).regionId(regionId).isActive(active)
.build();
}
}

View File

@@ -0,0 +1,577 @@
package cn.iocoder.yudao.module.education.service.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.integration.scalar.config.ScalarProperties;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentEntryDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogContentNodeDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogEntityDTO;
import cn.iocoder.yudao.module.education.service.catalog.dto.CatalogQuestionCollectionDTO;
import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarCatalogEntityDto;
import cn.iocoder.yudao.module.education.integration.scalar.dto.ScalarSingleItemResponse;
import org.springframework.core.ParameterizedTypeReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* ScalarCatalogProvider 契约测试 — 使用 MockRestServiceServer 模拟 Scalar API 行为。
*
* 覆盖正常列表、空数据、null body、null items、400→BAD_REQUEST、401/403/404/409/429、
* 5xx、超时、URI 编码(空格/非ASCII/&/=/特殊字符)、租户上下文、功能开关、配置验证。
*
* @author 恭学教育
*/
class ScalarCatalogProviderTest {
private ScalarCatalogProvider provider;
private MockRestServiceServer mockServer;
private RestTemplate restTemplate;
private ScalarProperties scalarProperties;
@BeforeEach
void setUp() {
scalarProperties = new ScalarProperties();
scalarProperties.setEnabled(true);
scalarProperties.setBaseUrl("http://mock-scalar");
scalarProperties.setToken("test-token");
scalarProperties.setConnectTimeout(Duration.ofSeconds(1));
scalarProperties.setReadTimeout(Duration.ofSeconds(2));
provider = new ScalarCatalogProvider(scalarProperties);
try {
var field = ScalarCatalogProvider.class.getDeclaredField("restTemplate");
field.setAccessible(true);
restTemplate = (RestTemplate) field.get(provider);
mockServer = MockRestServiceServer.createServer(restTemplate);
} catch (Exception e) {
throw new RuntimeException("Failed to access RestTemplate", e);
}
TenantContextHolder.setTenantId(2048L);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
if (mockServer != null) {
mockServer.verify();
}
}
// ========== 正常场景 ==========
@Test
void shouldReturnRegionsWhenScalarReturnsItems() {
String responseBody = """
{
"items": [
{"id": "r1", "name": "全国", "order": 1, "isActive": true},
{"id": "r2", "name": "华东", "order": 2, "isActive": true}
],
"meta": {"requestId": "req-123"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> {
assertEquals("/api/catalog/regions", request.getURI().getPath());
assertEquals("Bearer test-token", request.getHeaders().getFirst("Authorization"));
assertEquals("2048", request.getHeaders().getFirst("x-tenant-id"));
})
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
List<CatalogEntityDTO> regions = provider.listRegions();
assertNotNull(regions);
assertEquals(2, regions.size());
assertEquals("r1", regions.get(0).getId());
assertEquals("全国", regions.get(0).getName());
assertTrue(regions.get(0).getIsActive());
}
@Test
void shouldReturnEmptyListWhenScalarReturnsExplicitEmptyItems() {
String responseBody = """
{
"items": [],
"meta": {"requestId": "req-empty"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
List<CatalogEntityDTO> regions = provider.listRegions();
assertNotNull(regions);
assertTrue(regions.isEmpty());
}
@Test
void shouldThrowMalformedOnNullBody() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess());
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldThrowMalformedOnMissingItemsField() {
String responseBody = """
{
"meta": {"requestId": "req-no-items"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldMapToDomainDTOs() {
String responseBody = """
{
"items": [
{"id": "r1", "name": "全国", "title": "全国范围", "type": "region", "order": 1, "isActive": true}
],
"meta": {"requestId": "req-map"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
List<CatalogEntityDTO> regions = provider.listRegions();
assertEquals(1, regions.size());
CatalogEntityDTO dto = regions.get(0);
assertEquals("r1", dto.getId());
assertEquals("全国", dto.getName());
assertEquals("全国范围", dto.getTitle());
assertEquals("region", dto.getType());
assertTrue(dto.getIsActive());
}
@Test
void shouldMapContentEntriesToDomainDTOs() {
String responseBody = """
{
"items": [
{"id": "e1", "name": "高考数学", "entryKey": "gaokao-math", "entryType": "question_bank", "isActive": true}
],
"meta": {"requestId": "req-entry"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> {
assertEquals("/api/catalog/content-entries", request.getURI().getPath());
String query = request.getURI().getQuery();
assertNotNull(query);
assertTrue(query.contains("regionId=r1"));
})
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
List<CatalogContentEntryDTO> entries = provider.listContentEntries("r1", null, false);
assertEquals(1, entries.size());
assertEquals("e1", entries.get(0).getId());
assertEquals("高考数学", entries.get(0).getName());
assertEquals("gaokao-math", entries.get(0).getEntryKey());
assertEquals("question_bank", entries.get(0).getEntryType());
}
// ========== URI 编码测试 ==========
@Test
void shouldEncodeSpacesInQueryParams() {
String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-space\"}}";
mockServer.expect(ExpectedCount.once(),
request -> {
assertEquals("/api/catalog/categories", request.getURI().getPath());
String query = request.getURI().getQuery();
assertNotNull(query);
// UriComponentsBuilder encodes spaces as %20
assertTrue(query.contains("subjectId=hello%20world"),
"Expected encoded space, got: " + query);
})
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
provider.listCategories("hello world", null);
}
@Test
void shouldEncodeNonAsciiCharacters() {
String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-utf8\"}}";
mockServer.expect(ExpectedCount.once(),
request -> {
assertEquals("/api/catalog/categories", request.getURI().getPath());
String query = request.getURI().getQuery();
assertNotNull(query);
// "数学" → %E6%95%B0%E5%AD%A6
assertTrue(query.contains("%E6%95%B0%E5%AD%A6") || query.contains("数学"),
"Expected encoded non-ASCII, got: " + query);
})
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
provider.listCategories("数学", null);
}
@Test
void shouldEncodeAmpersandAndEquals() {
String responseBody = "{\"items\": [], \"meta\": {\"requestId\": \"req-special\"}}";
mockServer.expect(ExpectedCount.once(),
request -> {
assertEquals("/api/catalog/categories", request.getURI().getPath());
String query = request.getURI().getQuery();
assertNotNull(query);
// & → %26, = → %3D
assertTrue(query.contains("%26") || query.contains("%3D"),
"Expected encoded special chars, got: " + query);
})
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
provider.listCategories("a&b=c", null);
}
// ========== 错误场景 ==========
@Test
void shouldThrowBadRequestOn400() {
String errorBody = """
{"error": "regionId 格式无效", "code": "INVALID_PARAM", "requestId": "req-400", "meta": {"requestId": "req-400"}}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withBadRequest().body(errorBody).contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
// 400 uses GlobalErrorCodeConstants.BAD_REQUEST
assertEquals(400, ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamAuthFailedOn401() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.UNAUTHORIZED)
.body("{\"error\":\"unauthorized\",\"code\":\"UNAUTHORIZED\",\"requestId\":\"req-401\",\"meta\":{\"requestId\":\"req-401\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_AUTH_FAILED.getCode(), ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamForbiddenOn403() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.FORBIDDEN)
.body("{\"error\":\"forbidden\",\"code\":\"FORBIDDEN\",\"requestId\":\"req-403\",\"meta\":{\"requestId\":\"req-403\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_FORBIDDEN.getCode(), ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamNotFoundOn404() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.NOT_FOUND)
.body("{\"error\":\"not found\",\"code\":\"NOT_FOUND\",\"requestId\":\"req-404\",\"meta\":{\"requestId\":\"req-404\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_NOT_FOUND.getCode(), ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamConflictOn409() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.CONFLICT)
.body("{\"error\":\"conflict\",\"code\":\"CONFLICT\",\"requestId\":\"req-409\",\"meta\":{\"requestId\":\"req-409\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_CONFLICT.getCode(), ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamTooManyRequestsOn429() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.TOO_MANY_REQUESTS)
.body("{\"error\":\"rate limited\",\"code\":\"RATE_LIMITED\",\"requestId\":\"req-429\",\"meta\":{\"requestId\":\"req-429\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_TOO_MANY_REQUESTS.getCode(), ex.getCode());
}
@Test
void shouldThrowCatalogUpstreamErrorOn5xx() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
.body("{\"error\":\"internal\",\"code\":\"INTERNAL\",\"requestId\":\"req-500\",\"meta\":{\"requestId\":\"req-500\"}}")
.contentType(MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_ERROR.getCode(), ex.getCode());
}
@Test
void shouldThrowUnavailableOnConnectionRefused() {
ScalarProperties unavailableProps = new ScalarProperties();
unavailableProps.setEnabled(true);
unavailableProps.setBaseUrl("http://localhost:1");
unavailableProps.setToken("test-token");
unavailableProps.setConnectTimeout(Duration.ofMillis(10));
unavailableProps.setReadTimeout(Duration.ofMillis(10));
var unavailableProvider = new ScalarCatalogProvider(unavailableProps);
var ex = assertThrows(ServiceException.class,
() -> unavailableProvider.listRegions());
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
}
// ========== 租户上下文 ==========
@Test
void shouldDeriveTenantIdFromTenantContextHolder() {
TenantContextHolder.setTenantId(9999L);
String responseBody = """
{
"items": [{"id": "r1", "name": "全国", "isActive": true}],
"meta": {"requestId": "req-tenant"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("9999", request.getHeaders().getFirst("x-tenant-id")))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
List<CatalogEntityDTO> regions = provider.listRegions();
assertEquals(1, regions.size());
}
// ========== 功能开关 & 配置 ==========
@Test
void shouldReportEnabledStatus() {
assertTrue(provider.isEnabled());
}
@Test
void shouldReportDisabledWhenScalarDisabled() {
ScalarProperties disabled = new ScalarProperties();
disabled.setEnabled(false);
var p = new ScalarCatalogProvider(disabled);
assertFalse(p.isEnabled());
}
@Test
void shouldThrowOnConstructionWhenEnabledButMissingBaseUrl() {
ScalarProperties bad = new ScalarProperties();
bad.setEnabled(true);
bad.setBaseUrl(null);
bad.setToken("token");
var ex = assertThrows(ServiceException.class, () -> new ScalarCatalogProvider(bad));
assertEquals(CATALOG_SCALAR_NOT_CONFIGURED.getCode(), ex.getCode());
}
@Test
void shouldThrowOnConstructionWhenEnabledButMissingToken() {
ScalarProperties bad = new ScalarProperties();
bad.setEnabled(true);
bad.setBaseUrl("http://localhost");
bad.setToken(null);
var ex = assertThrows(ServiceException.class, () -> new ScalarCatalogProvider(bad));
assertEquals(CATALOG_SCALAR_NOT_CONFIGURED.getCode(), ex.getCode());
}
@Test
void shouldNotThrowOnConstructionWhenDisabledAndMissingConfig() {
ScalarProperties disabled = new ScalarProperties();
disabled.setEnabled(false);
disabled.setBaseUrl(null);
disabled.setToken(null);
// Should not throw — validation only when enabled
var p = new ScalarCatalogProvider(disabled);
assertFalse(p.isEnabled());
}
// ========== Fix 1: Singular item envelope ("item") ==========
@Test
void shouldParseSingularItemEnvelope() {
String responseBody = """
{
"item": {"id": "r-single", "name": "单独条目", "order": 1, "isActive": true},
"meta": {"requestId": "req-single"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var typeRef = new ParameterizedTypeReference<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
var item = provider.callSingleItem("/api/catalog/regions/99", typeRef);
assertNotNull(item);
assertEquals("r-single", item.getId());
assertEquals("单独条目", item.getName());
}
@Test
void shouldThrowMalformedOnNullItemInSingularEnvelope() {
String responseBody = """
{
"meta": {"requestId": "req-no-item"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var typeRef = new ParameterizedTypeReference<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
var ex = assertThrows(ServiceException.class,
() -> provider.callSingleItem("/api/catalog/regions/99", typeRef));
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldThrowMalformedOnNullBodyInSingularEnvelope() {
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions/99", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess());
var typeRef = new ParameterizedTypeReference<ScalarSingleItemResponse<ScalarCatalogEntityDto>>() {};
var ex = assertThrows(ServiceException.class,
() -> provider.callSingleItem("/api/catalog/regions/99", typeRef));
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
// ========== Fix 2: Malformed JSON / type mismatch → CATALOG_UPSTREAM_MALFORMED ==========
@Test
void shouldThrowMalformedOnInvalidJson() {
String responseBody = "this is not json at all {{{";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldThrowMalformedOnTypeMismatch() {
// items contains objects missing required fields → RestClientException when mapping
String responseBody = """
{
"items": "not_an_array_but_a_string",
"meta": {"requestId": "req-type"}
}""";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldThrowMalformedOnEmptyJsonObject() {
String responseBody = "{}";
mockServer.expect(ExpectedCount.once(),
request -> assertEquals("/api/catalog/regions", request.getURI().getPath()))
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
.withSuccess(responseBody, MediaType.APPLICATION_JSON));
var ex = assertThrows(ServiceException.class, () -> provider.listRegions());
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldThrowTimeoutOnReadTimeout() throws Exception {
// Use a delayed local HTTP server that accepts but never sends a response
var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress(0), 0);
server.createContext("/api/catalog/regions", exchange -> {
// Accept the connection and headers but delay past read timeout
try {
Thread.sleep(500); // longer than readTimeout below
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
exchange.close();
});
server.start();
int port = server.getAddress().getPort();
try {
ScalarProperties slowReadProps = new ScalarProperties();
slowReadProps.setEnabled(true);
slowReadProps.setBaseUrl("http://localhost:" + port);
slowReadProps.setToken("test-token");
slowReadProps.setConnectTimeout(Duration.ofSeconds(5));
slowReadProps.setReadTimeout(Duration.ofMillis(100));
var timeoutProvider = new ScalarCatalogProvider(slowReadProps);
var ex = assertThrows(ServiceException.class,
() -> timeoutProvider.listRegions());
assertEquals(CATALOG_UPSTREAM_TIMEOUT.getCode(), ex.getCode());
} finally {
server.stop(0);
}
}
}