forked from wangziqi/ruoyi-vue-pro
feat(education): add scalar catalog adapter
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user