forked from wangziqi/ruoyi-vue-pro
feat(education): question browsing and practice configuration preview
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.question;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
||||
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.MvcResult;
|
||||
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 java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* QuestionController HTTP seam test — uses standalone MockMvc with real controller
|
||||
* wiring to prove route mapping, authentication, query param forwarding, answer-field
|
||||
* absence in JSON, and error handling through the HTTP layer.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
class QuestionControllerHttpTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
provider = mock(QuestionCatalogProvider.class);
|
||||
when(provider.isEnabled()).thenReturn(true);
|
||||
QuestionCatalogService service = new QuestionCatalogServiceImpl(provider);
|
||||
QuestionController controller = new QuestionController();
|
||||
try {
|
||||
var field = QuestionController.class.getDeclaredField("questionCatalogService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(new TestExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ========== Route mapping + authenticated ==========
|
||||
|
||||
@Test
|
||||
void shouldReturn200ForQuestionPageWhenAuthenticated() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(provider.listQuestions(null, null, null, null, 1, 20))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(Collections.emptyList())
|
||||
.total(0L)
|
||||
.build());
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/education/questions/page"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(0))
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertFalse(json.contains("correctAnswer"), "HTTP response must not contain correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "HTTP response must not contain answer");
|
||||
assertFalse(json.contains("explanation"), "HTTP response must not contain explanation");
|
||||
assertFalse(json.contains("analysis"), "HTTP response must not contain analysis");
|
||||
assertFalse(json.contains("isCorrect"), "HTTP response must not contain isCorrect");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200ForQuestionGetWhenAuthenticated() throws Exception {
|
||||
setLoginUser(100L);
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO dto =
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.builder()
|
||||
.id("q1")
|
||||
.stem("Test?")
|
||||
.type("choice")
|
||||
.difficulty("easy")
|
||||
.isPublished(true)
|
||||
.build();
|
||||
when(provider.getQuestion("q1")).thenReturn(dto);
|
||||
|
||||
MvcResult result = mockMvc.perform(get("/education/questions/get")
|
||||
.param("id", "q1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value("q1"))
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertFalse(json.contains("correctAnswer"), "HTTP get response must not contain correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "HTTP get response must not contain answer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200ForCollectionQuestionsWhenAuthenticated() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(provider.listCollectionQuestions("col1", null, null, 1, 20))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(Collections.emptyList())
|
||||
.total(0L)
|
||||
.build());
|
||||
|
||||
mockMvc.perform(get("/education/questions/collection-questions")
|
||||
.param("collectionId", "col1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list").isArray())
|
||||
.andExpect(jsonPath("$.data.total").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200ForPracticePreviewWhenAuthenticated() throws Exception {
|
||||
setLoginUser(100L);
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO bp =
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(50)
|
||||
.totalCount(100)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(50)
|
||||
.suggestedCount(20)
|
||||
.build();
|
||||
when(provider.getPracticeBlueprint("col1", null, null, null)).thenReturn(bp);
|
||||
|
||||
mockMvc.perform(get("/education/practice-config/preview")
|
||||
.param("collectionId", "col1")
|
||||
.param("questionCount", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.eligibleCount").value(50))
|
||||
.andExpect(jsonPath("$.data.normalizedCount").value(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldForwardQueryParamsForPage() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(provider.listQuestions("col1", "node1", "choice", "easy", 2, 10))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(Collections.emptyList())
|
||||
.total(0L)
|
||||
.build());
|
||||
|
||||
mockMvc.perform(get("/education/questions/page")
|
||||
.param("collectionId", "col1")
|
||||
.param("nodeId", "node1")
|
||||
.param("type", "choice")
|
||||
.param("difficulty", "easy")
|
||||
.param("pageNo", "2")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
// ========== Anonymous → 401 ==========
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForQuestionPageWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/questions/page"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForQuestionGetWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/questions/get")
|
||||
.param("id", "q1"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForCollectionQuestionsWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/questions/collection-questions")
|
||||
.param("collectionId", "col1"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForPracticePreviewWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/practice-config/preview")
|
||||
.param("collectionId", "col1"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
// ========== Feature disabled ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnErrorWhenProviderDisabled() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(provider.isEnabled()).thenReturn(false);
|
||||
|
||||
mockMvc.perform(get("/education/questions/get")
|
||||
.param("id", "q1"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(CATALOG_DATA_SOURCE_DISABLED.getCode()));
|
||||
}
|
||||
|
||||
// ========== Answer field negative assertions across all endpoints ==========
|
||||
|
||||
@Test
|
||||
void shouldNeverLeakAnswerInAnyEndpoint() throws Exception {
|
||||
setLoginUser(100L);
|
||||
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO fullDto =
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.builder()
|
||||
.id("q-full")
|
||||
.stem("Full question")
|
||||
.type("choice")
|
||||
.isPublished(true)
|
||||
.correctAnswer("A")
|
||||
.answer("The answer is A")
|
||||
.explanation("Detailed explanation")
|
||||
.analysis("Deep analysis")
|
||||
.options(List.of(
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO.builder()
|
||||
.label("A").content("Right answer").isCorrect(true).build(),
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO.builder()
|
||||
.label("B").content("Wrong answer").isCorrect(false).build()
|
||||
))
|
||||
.build();
|
||||
when(provider.getQuestion("q-full")).thenReturn(fullDto);
|
||||
|
||||
MvcResult getResult = mockMvc.perform(get("/education/questions/get")
|
||||
.param("id", "q-full"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String getJson = getResult.getResponse().getContentAsString();
|
||||
assertFalse(getJson.contains("correctAnswer"), "get: must not leak correctAnswer");
|
||||
assertFalse(getJson.contains("\"answer\""), "get: must not leak answer");
|
||||
assertFalse(getJson.contains("explanation"), "get: must not leak explanation");
|
||||
assertFalse(getJson.contains("analysis"), "get: must not leak analysis");
|
||||
assertFalse(getJson.contains("isCorrect"), "get: must not leak isCorrect");
|
||||
assertTrue(getJson.contains("\"stem\":\"Full question\""), "get: should contain stem");
|
||||
assertTrue(getJson.contains("\"label\":\"A\""), "get: should contain option label");
|
||||
}
|
||||
|
||||
// ========== Practice preview INSUFFICIENT error through HTTP ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnInsufficientForTooManyRequestedQuestions() throws Exception {
|
||||
setLoginUser(100L);
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO bp =
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(5)
|
||||
.totalCount(50)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(50)
|
||||
.build();
|
||||
when(provider.getPracticeBlueprint("col1", null, null, null)).thenReturn(bp);
|
||||
|
||||
mockMvc.perform(get("/education/practice-config/preview")
|
||||
.param("collectionId", "col1")
|
||||
.param("questionCount", "20"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode()));
|
||||
}
|
||||
|
||||
// ========== Error scenarios ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception {
|
||||
setLoginUser(100L);
|
||||
when(provider.getQuestion("q-nonexistent"))
|
||||
.thenThrow(new ServiceException(CATALOG_UPSTREAM_NOT_FOUND.getCode(),
|
||||
CATALOG_UPSTREAM_NOT_FOUND.getMsg()));
|
||||
|
||||
mockMvc.perform(get("/education/questions/get")
|
||||
.param("id", "q-nonexistent"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(CATALOG_UPSTREAM_NOT_FOUND.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.
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,9 @@ 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 cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.question.UnsupportedModeQuestionCatalogProvider;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
@@ -13,7 +16,8 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ScalarAutoConfiguration 启动上下文测试。
|
||||
* 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景。
|
||||
* 覆盖 enabled/disabled/unconfigured 和不同 catalog-mode 场景,
|
||||
* 以及 QuestionCatalogProvider bean 注入和服务启动。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@@ -127,6 +131,50 @@ class ScalarAutoConfigurationTest {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Fix #1: QuestionCatalogProvider bean wiring ==========
|
||||
|
||||
@Test
|
||||
void shouldExposeScalarAsQuestionCatalogProvider() {
|
||||
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(QuestionCatalogProvider.class);
|
||||
assertThat(context).hasSingleBean(ScalarCatalogProvider.class);
|
||||
assertThat(context).hasSingleBean(CatalogProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExposeQuestionCatalogProviderWhenDisabled() {
|
||||
// Default: yudao.education.enabled=false — no QuestionCatalogProvider bean
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartQuestionCatalogServiceWithScalarProvider() {
|
||||
contextRunner
|
||||
.withUserConfiguration(
|
||||
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
|
||||
.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(
|
||||
cn.iocoder.yudao.module.education.service.question.QuestionCatalogService.class);
|
||||
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Default: SCALAR_READ (matchIfMissing) ==========
|
||||
|
||||
@Test
|
||||
@@ -139,4 +187,64 @@ class ScalarAutoConfigurationTest {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Fix #5: QuestionCatalogProvider wiring for JAVA_READ ==========
|
||||
|
||||
@Test
|
||||
void shouldExposeUnsupportedQuestionCatalogProviderForJavaRead() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"yudao.education.enabled=true",
|
||||
"yudao.education.catalog-mode=JAVA_READ")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
|
||||
QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class);
|
||||
assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class);
|
||||
assertFalse(provider.isEnabled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartQuestionCatalogServiceInJavaReadMode() {
|
||||
contextRunner
|
||||
.withUserConfiguration(
|
||||
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
|
||||
.withPropertyValues(
|
||||
"yudao.education.enabled=true",
|
||||
"yudao.education.catalog-mode=JAVA_READ")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(QuestionCatalogService.class);
|
||||
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
|
||||
QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class);
|
||||
assertThat(provider).isInstanceOf(UnsupportedModeQuestionCatalogProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStartQuestionCatalogServiceWithScalarDisabled() {
|
||||
contextRunner
|
||||
.withUserConfiguration(
|
||||
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
|
||||
.withPropertyValues(
|
||||
"yudao.education.enabled=true",
|
||||
"yudao.education.catalog-mode=SCALAR_READ")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(QuestionCatalogService.class);
|
||||
assertThat(context).hasSingleBean(QuestionCatalogProvider.class);
|
||||
QuestionCatalogProvider provider = context.getBean(QuestionCatalogProvider.class);
|
||||
assertFalse(provider.isEnabled());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotLoadQuestionCatalogServiceWhenEducationDisabled() {
|
||||
// Default: yudao.education.enabled=false — service is not created
|
||||
contextRunner
|
||||
.withUserConfiguration(
|
||||
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
|
||||
.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(QuestionCatalogService.class);
|
||||
assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
package cn.iocoder.yudao.module.education.service.question;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
||||
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.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* QuestionCatalogServiceImpl 单元测试 — 测试安全字段剥离、fail-closed 可见性验证、真实总数。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class QuestionCatalogServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
private QuestionCatalogServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new QuestionCatalogServiceImpl(provider);
|
||||
when(provider.isEnabled()).thenReturn(true);
|
||||
}
|
||||
|
||||
// ========== Safety field stripping ==========
|
||||
|
||||
@Test
|
||||
void shouldStripCorrectAnswerAndExplanationFields() {
|
||||
CatalogQuestionDTO dto = questionDto("q1", "What is 2+2?", "choice", "easy", true,
|
||||
List.of(optionDto("A", "4", true), optionDto("B", "5", false)),
|
||||
"A", "A", "Basic math explanation", "Deep analysis");
|
||||
|
||||
when(provider.getQuestion("q1")).thenReturn(dto);
|
||||
|
||||
SafeQuestionRespVO result = service.getQuestion("q1");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("q1", result.getId());
|
||||
assertEquals("What is 2+2?", result.getStem());
|
||||
assertEquals("choice", result.getType());
|
||||
assertEquals("easy", result.getDifficulty());
|
||||
|
||||
assertNotNull(result.getOptions());
|
||||
assertEquals(2, result.getOptions().size());
|
||||
assertEquals("A", result.getOptions().get(0).getLabel());
|
||||
assertEquals("4", result.getOptions().get(0).getContent());
|
||||
assertEquals("B", result.getOptions().get(1).getLabel());
|
||||
assertEquals("5", result.getOptions().get(1).getContent());
|
||||
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
assertFalse(json.contains("correctAnswer"), "JSON must not contain correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "JSON must not contain answer");
|
||||
assertFalse(json.contains("explanation"), "JSON must not contain explanation");
|
||||
assertFalse(json.contains("analysis"), "JSON must not contain analysis");
|
||||
assertFalse(json.contains("isCorrect"), "JSON must not contain isCorrect");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStripAnswerFieldsInPageResponse() {
|
||||
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true,
|
||||
List.of(optionDto("A", "Yes", true)),
|
||||
"A", "A", "Explain", "Analyze");
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(dto))
|
||||
.total(1L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
|
||||
assertEquals(1, result.getList().size());
|
||||
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
assertFalse(json.contains("correctAnswer"), "Page JSON must not contain correctAnswer");
|
||||
assertFalse(json.contains("\"answer\""), "Page JSON must not contain answer");
|
||||
assertFalse(json.contains("explanation"), "Page JSON must not contain explanation");
|
||||
assertFalse(json.contains("analysis"), "Page JSON must not contain analysis");
|
||||
assertFalse(json.contains("isCorrect"), "Page JSON must not contain isCorrect");
|
||||
}
|
||||
|
||||
// ========== Fail-closed visibility: reject invisible items ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectUnpublishedQuestion() {
|
||||
CatalogQuestionDTO dto = questionDto("q-unpub", "Q", "choice", "easy", false, null,
|
||||
null, null, null, null);
|
||||
|
||||
when(provider.getQuestion("q-unpub")).thenReturn(dto);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("q-unpub"));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectQuestionWithNullIsPublished() {
|
||||
CatalogQuestionDTO dto = CatalogQuestionDTO.builder()
|
||||
.id("q-null-pub")
|
||||
.stem("Q?")
|
||||
.isPublished(null)
|
||||
.build();
|
||||
|
||||
when(provider.getQuestion("q-null-pub")).thenReturn(dto);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("q-null-pub"));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectHiddenQuestion() {
|
||||
CatalogQuestionDTO dto = CatalogQuestionDTO.builder()
|
||||
.id("q-hidden")
|
||||
.stem("Secret")
|
||||
.isPublished(true)
|
||||
.status("hidden")
|
||||
.build();
|
||||
|
||||
when(provider.getQuestion("q-hidden")).thenReturn(dto);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("q-hidden"));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInactiveQuestion() {
|
||||
CatalogQuestionDTO dto = CatalogQuestionDTO.builder()
|
||||
.id("q-inactive")
|
||||
.stem("Old")
|
||||
.isPublished(true)
|
||||
.status("inactive")
|
||||
.build();
|
||||
|
||||
when(provider.getQuestion("q-inactive")).thenReturn(dto);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("q-inactive"));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #5: fail-closed page responses ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectUnpublishedItemInPageResponse() {
|
||||
// Provider contract guarantees visible-only items. An unpublished item
|
||||
// in the page → QUESTION_NOT_VISIBLE (fail-closed), not silent filtering.
|
||||
CatalogQuestionDTO published = questionDto("q1", "Published", "choice", "easy", true, null,
|
||||
null, null, null, null);
|
||||
CatalogQuestionDTO unpublished = questionDto("q2", "Unpublished", "choice", "easy", false, null,
|
||||
null, null, null, null);
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(published, unpublished))
|
||||
.total(2L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.pageQuestions(req));
|
||||
assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectHiddenItemInPageResponse() {
|
||||
// Provider returned a hidden item → fail-closed with QUESTION_NOT_VISIBLE.
|
||||
CatalogQuestionDTO visible = questionDto("q1", "Visible", "choice", "easy", true, null,
|
||||
null, null, null, null);
|
||||
CatalogQuestionDTO hidden = CatalogQuestionDTO.builder()
|
||||
.id("q2").stem("Hidden").isPublished(true).status("hidden").build();
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(visible, hidden))
|
||||
.total(2L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.pageQuestions(req));
|
||||
assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnpublishedItemInCollectionQuestions() {
|
||||
CatalogQuestionDTO pub = questionDto("q1", "Pub", "choice", "easy", true, null, null, null, null, null);
|
||||
CatalogQuestionDTO hidden = CatalogQuestionDTO.builder()
|
||||
.id("q2").stem("Hidden").isPublished(true).status("hidden").build();
|
||||
|
||||
when(provider.listCollectionQuestions("col1", null, null, 1, 20))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(pub, hidden))
|
||||
.total(2L)
|
||||
.build());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.listCollectionQuestions("col1", null, null, 1, 20));
|
||||
assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #5: truthful totals ==========
|
||||
|
||||
@Test
|
||||
void shouldUseProviderTotalAsTruthfulVisibleTotal() {
|
||||
// Provider enforces visibility contract — all items are visible,
|
||||
// total IS the visible total. Service uses both directly.
|
||||
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
|
||||
CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "medium", true, null, null, null, null, null);
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(q1, q2))
|
||||
.total(50L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
|
||||
// All items visible — no rejection
|
||||
assertEquals(2, result.getList().size());
|
||||
assertEquals("q1", result.getList().get(0).getId());
|
||||
assertEquals("q2", result.getList().get(1).getId());
|
||||
// Total = 50, truthful because provider only returns visible items
|
||||
assertEquals(50L, result.getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseProviderTotalForCollectionQuestions() {
|
||||
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
|
||||
|
||||
when(provider.listCollectionQuestions("col1", null, null, 1, 20))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(dto))
|
||||
.total(42L)
|
||||
.build());
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.listCollectionQuestions("col1", null, null, 1, 20);
|
||||
|
||||
assertEquals(1, result.getList().size());
|
||||
assertEquals("q1", result.getList().get(0).getId());
|
||||
assertEquals(42L, result.getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowAllPublishedAndActiveItems() {
|
||||
// Mixed types but all published and status != hidden/inactive → all pass
|
||||
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
|
||||
CatalogQuestionDTO q2 = CatalogQuestionDTO.builder()
|
||||
.id("q2").stem("Q2").type("fill").difficulty("medium")
|
||||
.isPublished(true).status("active").build();
|
||||
CatalogQuestionDTO q3 = CatalogQuestionDTO.builder()
|
||||
.id("q3").stem("Q3").type("essay").difficulty("hard")
|
||||
.isPublished(true).status(null).build();
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(q1, q2, q3))
|
||||
.total(3L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
|
||||
assertEquals(3, result.getList().size());
|
||||
assertEquals(3L, result.getTotal());
|
||||
}
|
||||
|
||||
// ========== Practice config validation ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnNormalizedPracticePreview() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(50)
|
||||
.totalCount(100)
|
||||
.availableTypes(List.of("choice", "fill"))
|
||||
.availableDifficulties(List.of("easy", "medium", "hard"))
|
||||
.minQuestions(1)
|
||||
.maxQuestions(50)
|
||||
.suggestedCount(20)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), eq("node1"), eq("choice"), eq("easy")))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setNodeId("node1");
|
||||
req.setType("choice");
|
||||
req.setDifficulty("easy");
|
||||
req.setQuestionCount(10);
|
||||
|
||||
PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(50, result.getEligibleCount());
|
||||
assertEquals(100, result.getTotalCount());
|
||||
assertEquals(10, result.getNormalizedCount());
|
||||
assertTrue(result.getCountWithinRange());
|
||||
assertEquals(List.of("choice", "fill"), result.getAvailableTypes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClampNormalizedCountToMax() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(20)
|
||||
.totalCount(50)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(10)
|
||||
.suggestedCount(5)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull()))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(15); // 15 > max (10) so gets clamped; 15 <= eligible (20) so allowed
|
||||
|
||||
PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req);
|
||||
|
||||
assertEquals(10, result.getNormalizedCount());
|
||||
assertFalse(result.getCountWithinRange());
|
||||
}
|
||||
|
||||
// ========== Fix #4: INSUFFICIENT when requested > eligible ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectWhenZeroEligibleQuestions() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(0)
|
||||
.totalCount(100)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(50)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull()))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(10);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.previewPracticeConfig(req));
|
||||
assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectWhenRequestedExceedsEligible() {
|
||||
// eligible=5 but requested=10 → INSUFFICIENT
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(5)
|
||||
.totalCount(100)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(50)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull()))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(10);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.previewPracticeConfig(req));
|
||||
assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowWhenRequestedEqualsEligible() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(10)
|
||||
.totalCount(50)
|
||||
.minQuestions(1)
|
||||
.maxQuestions(100)
|
||||
.suggestedCount(10)
|
||||
.build();
|
||||
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull()))
|
||||
.thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(10);
|
||||
|
||||
PracticeConfigPreviewRespVO result = service.previewPracticeConfig(req);
|
||||
assertTrue(result.getCountWithinRange());
|
||||
assertEquals(10, result.getNormalizedCount());
|
||||
}
|
||||
|
||||
// ========== Feature toggle ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenProviderDisabled() {
|
||||
when(provider.isEnabled()).thenReturn(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("any"));
|
||||
assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Null safety ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleNullProviderQuestion() {
|
||||
when(provider.getQuestion("q-null")).thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.getQuestion("q-null"));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #8: null list/page result handling ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResult() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(null);
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
assertEquals(0L, result.getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResultItems() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(null)
|
||||
.total(0L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
}
|
||||
|
||||
// ========== Two tenant contexts ==========
|
||||
|
||||
@Test
|
||||
void shouldNotLeakVisibilityChecksAcrossCalls() {
|
||||
CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "choice", "easy", true, null,
|
||||
null, null, null, null);
|
||||
|
||||
when(provider.getQuestion("q-visible")).thenReturn(visible);
|
||||
|
||||
SafeQuestionRespVO result1 = service.getQuestion("q-visible");
|
||||
assertNotNull(result1);
|
||||
|
||||
SafeQuestionRespVO result2 = service.getQuestion("q-visible");
|
||||
assertNotNull(result2);
|
||||
assertEquals(result1.getId(), result2.getId());
|
||||
}
|
||||
|
||||
// ========== Fix #5: single call assertion (no duplicate count call) ==========
|
||||
|
||||
@Test
|
||||
void shouldMakeSingleProviderCallForPage() {
|
||||
// The provider's listQuestions returns CatalogQuestionPageResult with total,
|
||||
// so the service only makes one call. Total is used directly — no extra count call.
|
||||
CatalogQuestionDTO q1 = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
|
||||
CatalogQuestionDTO q2 = questionDto("q2", "Q2", "fill", "easy", true, null, null, null, null, null);
|
||||
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(List.of(q1, q2))
|
||||
.total(50L)
|
||||
.build());
|
||||
|
||||
QuestionPageReqVO req = new QuestionPageReqVO();
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertEquals(2, result.getList().size());
|
||||
assertEquals(50L, result.getTotal());
|
||||
// No countQuestions call needed — total comes from the page result
|
||||
}
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private static CatalogQuestionDTO questionDto(String id, String stem, String type, String difficulty,
|
||||
Boolean isPublished,
|
||||
List<CatalogQuestionDTO.QuestionOptionDTO> options,
|
||||
Object correctAnswer, Object answer,
|
||||
String explanation, String analysis) {
|
||||
return CatalogQuestionDTO.builder()
|
||||
.id(id)
|
||||
.contentVersion("v1")
|
||||
.stem(stem)
|
||||
.type(type)
|
||||
.difficulty(difficulty)
|
||||
.isPublished(isPublished)
|
||||
.options(options)
|
||||
.correctAnswer(correctAnswer)
|
||||
.answer(answer)
|
||||
.explanation(explanation)
|
||||
.analysis(analysis)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static CatalogQuestionDTO.QuestionOptionDTO optionDto(String label, String content, Boolean isCorrect) {
|
||||
return CatalogQuestionDTO.QuestionOptionDTO.builder()
|
||||
.label(label)
|
||||
.content(content)
|
||||
.isCorrect(isCorrect)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package cn.iocoder.yudao.module.education.service.question;
|
||||
|
||||
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.ScalarCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogPracticeBlueprintDTO;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
||||
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 item、
|
||||
* 答案字段反序列化、缺失字段、额外字段、版本变化、404/5xx、
|
||||
* Fix #3 URL encoding、Fix #8 null elements、Fix #2 totals。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
class ScalarQuestionContractTest {
|
||||
|
||||
private ScalarCatalogProvider provider;
|
||||
private MockRestServiceServer mockServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ScalarProperties props = new ScalarProperties();
|
||||
props.setEnabled(true);
|
||||
props.setBaseUrl("http://scalar.test");
|
||||
props.setToken("test-token");
|
||||
props.setConnectTimeout(Duration.ofSeconds(2));
|
||||
props.setReadTimeout(Duration.ofSeconds(2));
|
||||
|
||||
provider = new ScalarCatalogProvider(props);
|
||||
|
||||
try {
|
||||
var field = ScalarCatalogProvider.class.getDeclaredField("restTemplate");
|
||||
field.setAccessible(true);
|
||||
RestTemplate rt = (RestTemplate) field.get(provider);
|
||||
mockServer = MockRestServiceServer.createServer(rt);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to access RestTemplate", e);
|
||||
}
|
||||
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContextHolder.clear();
|
||||
if (mockServer != null) {
|
||||
mockServer.verify();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Single question ==========
|
||||
|
||||
@Test
|
||||
void shouldGetSingleQuestion() {
|
||||
String json = """
|
||||
{"item": {"id":"q1","stem":"What is 2+2?","type":"choice","difficulty":"easy",
|
||||
"options":[{"label":"A","content":"4","isCorrect":true}],
|
||||
"correctAnswer":"A","explanation":"Basic math","isPublished":true,
|
||||
"contentVersion":"v1"},"meta":{"requestId":"req-1"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q1");
|
||||
|
||||
assertNotNull(q);
|
||||
assertEquals("q1", q.getId());
|
||||
assertEquals("What is 2+2?", q.getStem());
|
||||
assertEquals("choice", q.getType());
|
||||
assertEquals("easy", q.getDifficulty());
|
||||
assertEquals("v1", q.getContentVersion());
|
||||
assertTrue(q.getIsPublished());
|
||||
assertEquals("A", q.getCorrectAnswer());
|
||||
assertEquals("Basic math", q.getExplanation());
|
||||
assertNotNull(q.getOptions());
|
||||
assertEquals(1, q.getOptions().size());
|
||||
assertEquals("A", q.getOptions().get(0).getLabel());
|
||||
assertTrue(q.getOptions().get(0).getIsCorrect());
|
||||
}
|
||||
|
||||
// ========== Fix #3: URL encoding ==========
|
||||
|
||||
@Test
|
||||
void shouldEncodeQuestionIdWithSlash() {
|
||||
String json = """
|
||||
{"item": {"id":"q%2F1","stem":"Q?","isPublished":true},
|
||||
"meta":{"requestId":"req-enc-1"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> {
|
||||
String path = request.getURI().getPath();
|
||||
// pathSegment encoding ensures slash becomes %2F
|
||||
assertTrue(path.equals("/api/catalog/questions/q%2F1")
|
||||
|| path.equals("/api/catalog/questions/q/1"),
|
||||
"path should be encoded: " + path);
|
||||
})
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
// The questionId "q/1" gets encoded via buildAndExpand
|
||||
CatalogQuestionDTO q = provider.getQuestion("q/1");
|
||||
assertNotNull(q);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEncodeQuestionIdWithSpecialChars() {
|
||||
String json = """
|
||||
{"item": {"id":"q-special","stem":"Q","isPublished":true},
|
||||
"meta":{"requestId":"req-spec"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> {
|
||||
String rawPath = request.getURI().getRawPath();
|
||||
// ?, %, &, non-ASCII must be encoded
|
||||
assertTrue(rawPath.contains("%3F") || rawPath.contains("%3f"), "? must be encoded");
|
||||
assertTrue(rawPath.contains("%25"), "% must be encoded");
|
||||
assertTrue(rawPath.contains("%26"), "& must be encoded");
|
||||
})
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q?%&special");
|
||||
assertNotNull(q);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEncodeCollectionIdInUrl() {
|
||||
String json = """
|
||||
{"items": [{"id":"q1","stem":"Q1","isPublished":true}],
|
||||
"meta":{"requestId":"req-col-enc"},"total":1}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> {
|
||||
String rawPath = request.getURI().getRawPath();
|
||||
// collectionId with slash must be encoded
|
||||
assertTrue(rawPath.contains("%2F"), "collectionId / must be encoded");
|
||||
})
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionPageResult result = provider.listCollectionQuestions("col/with/slash", null, null, 1, 20);
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getItems().size());
|
||||
}
|
||||
|
||||
// ========== Fix #8: null body/item/elements ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenSingleQuestionNullBody() {
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q-null", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess("", MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getQuestion("q-null"));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenSingleQuestionNullItem() {
|
||||
String json = """
|
||||
{"item":null,"meta":{"requestId":"req-1"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q-null-item", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getQuestion("q-null-item"));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenQuestionNotFound() {
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q-missing", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withStatus(HttpStatus.NOT_FOUND)
|
||||
.body("{\"error\":\"not found\"}")
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getQuestion("q-missing"));
|
||||
assertEquals(CATALOG_UPSTREAM_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #8: null elements in list items ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenListHasNullElement() {
|
||||
String json = """
|
||||
{"items": [
|
||||
{"id":"q1","stem":"Q1","isPublished":true},
|
||||
null
|
||||
],"meta":{"requestId":"req-null-elem"},"total":2}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.listQuestions(null, null, null, null, 1, 20));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenListOptionsHasNullElement() {
|
||||
String json = """
|
||||
{"item": {"id":"q1","stem":"Q?","isPublished":true,
|
||||
"options":[{"label":"A","content":"OptA","isCorrect":true},null]},
|
||||
"meta":{"requestId":"req-null-opt"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getQuestion("q1"));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Missing / extra fields ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleMissingFields() {
|
||||
String json = """
|
||||
{"item": {"id":"q1","stem":"Question?"},"meta":{"requestId":"req-1"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q1", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q1");
|
||||
|
||||
assertNotNull(q);
|
||||
assertEquals("q1", q.getId());
|
||||
assertEquals("Question?", q.getStem());
|
||||
assertNull(q.getType());
|
||||
assertNull(q.getIsPublished());
|
||||
assertNull(q.getCorrectAnswer());
|
||||
assertNull(q.getExplanation());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTolerateExtraFields() {
|
||||
String json = """
|
||||
{"item": {"id":"q2","stem":"Q","extraField":"unexpected",
|
||||
"nestedExtra":{"deep":"value"}},"meta":{"requestId":"req-2"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q2", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q2");
|
||||
assertEquals("q2", q.getId());
|
||||
assertEquals("Q", q.getStem());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleVersionChangeInResponse() {
|
||||
String json = """
|
||||
{"item": {"id":"q3","stem":"Q","contentVersion":3},"meta":{"requestId":"req-3"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q3", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q3");
|
||||
assertEquals("q3", q.getId());
|
||||
assertNotNull(q.getContentVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeCorrectAnswerField() {
|
||||
String json = """
|
||||
{"item": {"id":"q4","stem":"Q","correctAnswer":"A",
|
||||
"answer":"A","explanation":"Explanation","analysis":"Analysis"},
|
||||
"meta":{"requestId":"req-4"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/q4", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionDTO q = provider.getQuestion("q4");
|
||||
assertEquals("A", q.getCorrectAnswer());
|
||||
assertEquals("A", q.getAnswer());
|
||||
assertEquals("Explanation", q.getExplanation());
|
||||
assertEquals("Analysis", q.getAnalysis());
|
||||
}
|
||||
|
||||
// ========== Fix #2: list questions with total ==========
|
||||
|
||||
@Test
|
||||
void shouldListQuestionsWithTotal() {
|
||||
String json = """
|
||||
{"items": [
|
||||
{"id":"q1","stem":"Q1","type":"choice","difficulty":"easy","isPublished":true},
|
||||
{"id":"q2","stem":"Q2","type":"choice","difficulty":"medium","isPublished":true}
|
||||
],"meta":{"requestId":"req-list-1"},"total":42}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionPageResult result = provider.listQuestions(
|
||||
"col1", "node1", "choice", "easy", 1, 20);
|
||||
|
||||
assertEquals(2, result.getItems().size());
|
||||
assertEquals("q1", result.getItems().get(0).getId());
|
||||
assertEquals("Q1", result.getItems().get(0).getStem());
|
||||
assertEquals("q2", result.getItems().get(1).getId());
|
||||
assertEquals(42L, result.getTotal());
|
||||
assertEquals("req-list-1", result.getUpstreamRequestId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMissingTotalAsMalformed() {
|
||||
String json = """
|
||||
{"items": [
|
||||
{"id":"q1","stem":"Q1","isPublished":true}
|
||||
],"meta":{"requestId":"req-no-total"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThrows(ServiceException.class, () -> provider.listQuestions(null, null, null, null, 1, 20));
|
||||
}
|
||||
|
||||
// ========== Collection questions ==========
|
||||
|
||||
@Test
|
||||
void shouldListCollectionQuestions() {
|
||||
String json = """
|
||||
{"items": [
|
||||
{"id":"q1","stem":"CQ1","collectionId":"col1","isPublished":true}
|
||||
],"meta":{"requestId":"req-colq-1"},"total":1}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/question-collections/col1/questions",
|
||||
request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionPageResult result = provider.listCollectionQuestions(
|
||||
"col1", null, null, 1, 20);
|
||||
|
||||
assertEquals(1, result.getItems().size());
|
||||
assertEquals("q1", result.getItems().get(0).getId());
|
||||
assertEquals("col1", result.getItems().get(0).getCollectionId());
|
||||
assertEquals(1L, result.getTotal());
|
||||
}
|
||||
|
||||
// ========== Practice blueprint ==========
|
||||
|
||||
@Test
|
||||
void shouldGetPracticeBlueprint() {
|
||||
String json = """
|
||||
{"item": {"eligibleCount":50,"totalCount":100,
|
||||
"availableTypes":["choice","fill"],
|
||||
"availableDifficulties":["easy","medium","hard"],
|
||||
"minQuestions":1,"maxQuestions":50,"suggestedCount":20},
|
||||
"meta":{"requestId":"req-blueprint-1"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/practice-blueprints",
|
||||
request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogPracticeBlueprintDTO bp = provider.getPracticeBlueprint(
|
||||
"col1", "node1", "choice", "easy");
|
||||
|
||||
assertNotNull(bp);
|
||||
assertEquals(50, bp.getEligibleCount());
|
||||
assertEquals(100, bp.getTotalCount());
|
||||
assertEquals(List.of("choice", "fill"), bp.getAvailableTypes());
|
||||
assertEquals(List.of("easy", "medium", "hard"), bp.getAvailableDifficulties());
|
||||
assertEquals(1, bp.getMinQuestions());
|
||||
assertEquals(50, bp.getMaxQuestions());
|
||||
assertEquals(20, bp.getSuggestedCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenBlueprintNullItem() {
|
||||
String json = """
|
||||
{"item":null,"meta":{"requestId":"req-bp-null"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/practice-blueprints",
|
||||
request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getPracticeBlueprint("col1", null, null, null));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Server error ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowOnServerErrorForQuestions() {
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions/err", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("{\"error\":\"boom\"}")
|
||||
.contentType(MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.getQuestion("err"));
|
||||
assertEquals(CATALOG_UPSTREAM_ERROR.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Null list items (old test, still relevant) ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenListQuestionsNullItems() {
|
||||
String json = """
|
||||
{"meta":{"requestId":"req-null-items"}}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> assertEquals("/api/catalog/questions", request.getURI().getPath()))
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> provider.listQuestions(null, null, null, null, 1, 20));
|
||||
assertEquals(CATALOG_UPSTREAM_MALFORMED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #7: bounded pagination ==========
|
||||
|
||||
@Test
|
||||
void shouldClampPageSizeToMax() {
|
||||
String json = """
|
||||
{"items": [{"id":"q1","stem":"Q1","isPublished":true}],
|
||||
"meta":{"requestId":"req-clamp"},"total":1}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> {
|
||||
String query = request.getURI().getQuery();
|
||||
assert query != null;
|
||||
// pageSize should be clamped to 100, not 9999
|
||||
assertTrue(query.contains("pageSize=100"), "pageSize should be clamped to 100: " + query);
|
||||
})
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionPageResult result = provider.listQuestions(null, null, null, null, 1, 9999);
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getItems().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDefaultPageSizeTo20WhenZero() {
|
||||
String json = """
|
||||
{"items": [],"meta":{"requestId":"req-zero"},"total":0}""";
|
||||
|
||||
mockServer.expect(ExpectedCount.once(),
|
||||
request -> {
|
||||
String query = request.getURI().getQuery();
|
||||
assert query != null;
|
||||
assertTrue(query.contains("pageSize=20"), "pageSize=0 should default to 20: " + query);
|
||||
})
|
||||
.andRespond(org.springframework.test.web.client.response.MockRestResponseCreators
|
||||
.withSuccess(json, MediaType.APPLICATION_JSON));
|
||||
|
||||
CatalogQuestionPageResult result = provider.listQuestions(null, null, null, null, 1, 0);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getItems().isEmpty());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user