feat(education): submit sessions and persist reports

This commit is contained in:
2026-07-27 22:39:39 +08:00
parent 046bb4efee
commit 4cb18b844a
23 changed files with 2654 additions and 98 deletions

View File

@@ -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) {}
/**

View File

@@ -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());
}
}
}

View File

@@ -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;

View File

@@ -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");