forked from wangziqi/ruoyi-vue-pro
feat(education): submit sessions and persist reports
This commit is contained in:
@@ -322,6 +322,33 @@ class PracticeSessionControllerHttpTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
// ========== Ticket #8 #6: Page bounds validation ==========
|
||||
|
||||
/**
|
||||
* Verify that @Min/@Max annotations are present on the report history endpoint parameters.
|
||||
* Method-level validation is handled by Spring's MethodValidationPostProcessor in production.
|
||||
*/
|
||||
@Test
|
||||
void shouldHavePageNoMinValidation() throws Exception {
|
||||
var method = PracticeSessionController.class.getMethod("getReportHistory", int.class, int.class);
|
||||
var params = method.getParameters();
|
||||
var min = params[0].getAnnotation(jakarta.validation.constraints.Min.class);
|
||||
assertNotNull(min, "pageNo must have @Min annotation");
|
||||
assertEquals(1L, min.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHavePageSizeMinAndMaxValidation() throws Exception {
|
||||
var method = PracticeSessionController.class.getMethod("getReportHistory", int.class, int.class);
|
||||
var params = method.getParameters();
|
||||
var min = params[1].getAnnotation(jakarta.validation.constraints.Min.class);
|
||||
assertNotNull(min, "pageSize must have @Min annotation");
|
||||
assertEquals(1L, min.value());
|
||||
var max = params[1].getAnnotation(jakarta.validation.constraints.Max.class);
|
||||
assertNotNull(max, "pageSize must have @Max annotation");
|
||||
assertEquals(100L, max.value());
|
||||
}
|
||||
private record EndpointTest(String method, String url, String body) {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.practice;
|
||||
|
||||
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.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.http.MediaType;
|
||||
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.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
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.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* PracticeSessionController HTTP seam test — submit + report endpoints.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
class PracticeSessionControllerSubmitHttpTest {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private PracticeSessionService service;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(PracticeSessionService.class);
|
||||
PracticeSessionController controller = new PracticeSessionController();
|
||||
try {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
var validator = new LocalValidatorFactoryBean();
|
||||
validator.afterPropertiesSet();
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setValidator(validator)
|
||||
.setControllerAdvice(new TestExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ========== POST /submit ==========
|
||||
|
||||
@Test
|
||||
void shouldSubmitAndReturnReportWhenAuthenticated() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
|
||||
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||
.reportId(5001L)
|
||||
.sessionId(1001L)
|
||||
.questionCount(10)
|
||||
.answeredCount(8)
|
||||
.unansweredCount(2)
|
||||
.correctCount(6)
|
||||
.incorrectCount(2)
|
||||
.score(75)
|
||||
.details(List.of(
|
||||
PracticeReportDetailRespVO.builder()
|
||||
.sequence(1)
|
||||
.questionId("q-001")
|
||||
.stem("1+1=?")
|
||||
.type("choice")
|
||||
.difficulty("easy")
|
||||
.selectedAnswer("B")
|
||||
.correctAnswer("B")
|
||||
.isCorrect(true)
|
||||
.explanation("1+1=2, B is correct")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(service.submitSession(any(), eq(100L), eq(1L))).thenReturn(mockResp);
|
||||
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(1001L);
|
||||
req.setIdempotencyKey("uuid-submit");
|
||||
req.setExpectedSessionVersion(5);
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/education/practice-session/submit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.reportId").value(5001))
|
||||
.andExpect(jsonPath("$.data.score").value(75))
|
||||
.andExpect(jsonPath("$.data.details[0].correctAnswer").value("B"))
|
||||
.andExpect(jsonPath("$.data.details[0].explanation").value("1+1=2, B is correct"))
|
||||
.andReturn();
|
||||
|
||||
String json = result.getResponse().getContentAsString();
|
||||
assertTrue(json.contains("correctAnswer"), "submit: must include correctAnswer");
|
||||
assertTrue(json.contains("explanation"), "submit: must include explanation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForSubmitWhenNotAuthenticated() throws Exception {
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(1001L);
|
||||
req.setIdempotencyKey("uuid-submit");
|
||||
req.setExpectedSessionVersion(1);
|
||||
|
||||
mockMvc.perform(post("/education/practice-session/submit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSubmitWhenSessionNotActive() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
when(service.submitSession(any(), eq(100L), eq(1L)))
|
||||
.thenThrow(new ServiceException(SUBMIT_SESSION_NOT_ACTIVE.getCode(), SUBMIT_SESSION_NOT_ACTIVE.getMsg()));
|
||||
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(1001L);
|
||||
req.setIdempotencyKey("uuid-submit");
|
||||
req.setExpectedSessionVersion(1);
|
||||
|
||||
mockMvc.perform(post("/education/practice-session/submit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(SUBMIT_SESSION_NOT_ACTIVE.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReplayOnDuplicateSubmit() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
|
||||
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||
.reportId(5001L)
|
||||
.sessionId(1001L)
|
||||
.questionCount(5)
|
||||
.score(80)
|
||||
.build();
|
||||
when(service.submitSession(any(), eq(100L), eq(1L))).thenReturn(mockResp);
|
||||
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(1001L);
|
||||
req.setIdempotencyKey("uuid-replay");
|
||||
req.setExpectedSessionVersion(3);
|
||||
|
||||
// Two calls — service handles idempotency
|
||||
mockMvc.perform(post("/education/practice-session/submit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.reportId").value(5001));
|
||||
|
||||
mockMvc.perform(post("/education/practice-session/submit")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.reportId").value(5001));
|
||||
|
||||
verify(service, times(2)).submitSession(any(), eq(100L), eq(1L));
|
||||
}
|
||||
|
||||
// ========== GET /report ==========
|
||||
|
||||
@Test
|
||||
void shouldGetReportWhenSubmitted() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
|
||||
PracticeSubmitRespVO mockResp = PracticeSubmitRespVO.builder()
|
||||
.reportId(5001L)
|
||||
.sessionId(1001L)
|
||||
.questionCount(10)
|
||||
.score(90)
|
||||
.details(List.of(
|
||||
PracticeReportDetailRespVO.builder()
|
||||
.sequence(1)
|
||||
.questionId("q-001")
|
||||
.stem("stem")
|
||||
.type("choice")
|
||||
.selectedAnswer("A")
|
||||
.correctAnswer("A")
|
||||
.isCorrect(true)
|
||||
.explanation("Correct!")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(service.getReport(1001L, 100L, 1L)).thenReturn(mockResp);
|
||||
|
||||
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.reportId").value(5001))
|
||||
.andExpect(jsonPath("$.data.score").value(90))
|
||||
.andExpect(jsonPath("$.data.details[0].explanation").value("Correct!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForReportWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReportForUnsubmittedSession() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
when(service.getReport(1001L, 100L, 1L))
|
||||
.thenThrow(new ServiceException(REPORT_SESSION_NOT_SUBMITTED.getCode(), REPORT_SESSION_NOT_SUBMITTED.getMsg()));
|
||||
|
||||
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(REPORT_SESSION_NOT_SUBMITTED.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReportForDifferentUser() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
when(service.getReport(1001L, 100L, 1L))
|
||||
.thenThrow(new ServiceException(REPORT_NOT_OWN.getCode(), REPORT_NOT_OWN.getMsg()));
|
||||
|
||||
mockMvc.perform(get("/education/practice-session/report").param("sessionId", "1001"))
|
||||
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
|
||||
.andExpect(jsonPath("$.code").value(REPORT_NOT_OWN.getCode()));
|
||||
}
|
||||
|
||||
// ========== GET /reports (history) ==========
|
||||
|
||||
@Test
|
||||
void shouldReturnPaginatedHistory() throws Exception {
|
||||
setLoginUser(100L, 1L);
|
||||
|
||||
PracticeSubmitRespVO report1 = PracticeSubmitRespVO.builder()
|
||||
.reportId(5001L).sessionId(1001L).score(80).build();
|
||||
PracticeSubmitRespVO report2 = PracticeSubmitRespVO.builder()
|
||||
.reportId(5002L).sessionId(1002L).score(90).build();
|
||||
|
||||
PageResult<PracticeSubmitRespVO> mockPage = new PageResult<>(
|
||||
List.of(report1, report2), 5L);
|
||||
when(service.getReportHistory(100L, 1L, 1, 2)).thenReturn(mockPage);
|
||||
|
||||
mockMvc.perform(get("/education/practice-session/reports")
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "2"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(5))
|
||||
.andExpect(jsonPath("$.data.list[0].reportId").value(5001))
|
||||
.andExpect(jsonPath("$.data.list[1].score").value(90));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401ForHistoryWhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/education/practice-session/reports"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
private void setLoginUser(Long userId, Long tenantId) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(userId);
|
||||
loginUser.setTenantId(tenantId);
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
@RestControllerAdvice
|
||||
static class TestExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public CommonResult<?> handleValidation(MethodArgumentNotValidException ex,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
String msg = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||
.findFirst().orElse("validation error");
|
||||
return CommonResult.error(400, msg);
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
DELETE FROM education_practice_report_detail;
|
||||
DELETE FROM education_practice_report;
|
||||
DELETE FROM education_submit_idempotency;
|
||||
DELETE FROM education_practice_question;
|
||||
DELETE FROM education_practice_session;
|
||||
DELETE FROM education_answer_idempotency;
|
||||
|
||||
@@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS "education_practice_question" (
|
||||
"selected_answer" CLOB DEFAULT NULL,
|
||||
"is_answered" BIT NOT NULL DEFAULT FALSE,
|
||||
"client_sequence" INT DEFAULT NULL,
|
||||
"correct_answer" CLOB DEFAULT NULL,
|
||||
"explanation" CLOB DEFAULT NULL,
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
@@ -69,3 +71,75 @@ CREATE TABLE IF NOT EXISTS "education_answer_idempotency" (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_tenant_session" ON "education_answer_idempotency" ("tenant_id", "session_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "education_submit_idempotency" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"operation" VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_SESSION',
|
||||
"idempotency_key" VARCHAR(64) NOT NULL,
|
||||
"request_hash" VARCHAR(64) NOT NULL,
|
||||
"session_id" BIGINT NOT NULL,
|
||||
"report_id" BIGINT DEFAULT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED',
|
||||
"response_json" CLOB NOT NULL,
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" BIT NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uk_submit_idempotency" UNIQUE ("tenant_id", "user_id", "operation", "idempotency_key")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_submit_session" ON "education_submit_idempotency" ("tenant_id", "session_id");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "education_practice_report" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"session_id" BIGINT NOT NULL,
|
||||
"question_count" INT NOT NULL,
|
||||
"answered_count" INT NOT NULL DEFAULT 0,
|
||||
"unanswered_count" INT NOT NULL DEFAULT 0,
|
||||
"correct_count" INT NOT NULL DEFAULT 0,
|
||||
"incorrect_count" INT NOT NULL DEFAULT 0,
|
||||
"score" INT NOT NULL DEFAULT 0,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" BIT NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uk_report_session" UNIQUE ("session_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_report_tenant_user" ON "education_practice_report" ("tenant_id", "user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_report_create_time" ON "education_practice_report" ("create_time");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"report_id" BIGINT NOT NULL,
|
||||
"session_id" BIGINT NOT NULL,
|
||||
"question_id" VARCHAR(64) NOT NULL,
|
||||
"sequence" INT NOT NULL,
|
||||
"stem" CLOB NOT NULL,
|
||||
"type" VARCHAR(32) NOT NULL,
|
||||
"difficulty" VARCHAR(32) DEFAULT NULL,
|
||||
"selected_answer" CLOB DEFAULT NULL,
|
||||
"correct_answer" CLOB DEFAULT NULL,
|
||||
"is_correct" BIT NOT NULL DEFAULT FALSE,
|
||||
"explanation" CLOB DEFAULT NULL,
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" BIT NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uk_report_sequence" UNIQUE ("report_id", "sequence")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_detail_session" ON "education_practice_report_detail" ("session_id");
|
||||
|
||||
Reference in New Issue
Block a user