forked from wangziqi/ruoyi-vue-pro
feat(education): project and review wrong questions
This commit is contained in:
@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.education.dal.mysql.AnswerIdempotencyMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -48,6 +49,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
|
||||
@MockitoBean
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
@MockitoBean
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
|
||||
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -47,6 +48,9 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
|
||||
@MockitoBean
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
@MockitoBean
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
// ========== Create: basic ==========
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package cn.iocoder.yudao.module.education.service.practice;
|
||||
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionServiceImpl;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test verifying that submitSession transactionally creates wrong
|
||||
* question projections. Uses real WrongQuestionServiceImpl (no mock) so the
|
||||
* full submit → wrong-question pipeline is exercised with real DB.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Import({PracticeSessionServiceImpl.class, WrongQuestionServiceImpl.class})
|
||||
public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private PracticeSessionService service;
|
||||
|
||||
@Resource
|
||||
private PracticeSessionMapper sessionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeQuestionMapper questionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeReportMapper reportMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeReportDetailMapper reportDetailMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionMapper wrongQuestionMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionIdempotencyMapper idempotencyMapper;
|
||||
|
||||
@MockitoBean
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
session.setCollectionId("col-001");
|
||||
session.setVersion(1);
|
||||
sessionMapper.insert(session);
|
||||
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
q.setDifficulty("easy");
|
||||
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
q.setCorrectAnswer(correctAnswer);
|
||||
q.setExplanation("Explanation " + (i + 1));
|
||||
q.setIsAnswered(false);
|
||||
questions.add(q);
|
||||
}
|
||||
questionMapper.insertBatch(questions);
|
||||
|
||||
return new SessionFixture(session.getId(), questions, session.getVersion());
|
||||
}
|
||||
|
||||
private void answerQuestion(Long questionId, String answer) {
|
||||
PracticeQuestionDO q = questionMapper.selectById(questionId);
|
||||
if (q != null) {
|
||||
q.setSelectedAnswer(answer);
|
||||
q.setIsAnswered(true);
|
||||
questionMapper.updateById(q);
|
||||
}
|
||||
}
|
||||
|
||||
private PracticeSubmitReqVO createSubmitReq(Long sessionId, String idempotencyKey, int expectedVersion) {
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(sessionId);
|
||||
req.setIdempotencyKey(idempotencyKey);
|
||||
req.setExpectedSessionVersion(expectedVersion);
|
||||
return req;
|
||||
}
|
||||
|
||||
// ========== Submit creates wrong question projection ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateWrongQuestionProjectionOnSubmit() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-create", 3, "B");
|
||||
// Answer q1 wrong (A), q2 correct (B), q3 unanswered
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
answerQuestion(f.questions.get(1).getId(), "B");
|
||||
// q3: no answer
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-create", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Only q1 is wrong → one wrong question entry
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
WrongQuestionDO wq = wqs.get(0);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
assertEquals(1, wq.getWrongCount());
|
||||
assertNotNull(wq.getOptions(), "options must be populated from detail");
|
||||
assertNotEquals("[]", wq.getOptions(), "options must not be empty placeholder");
|
||||
assertEquals("v1", wq.getContentVersion(),
|
||||
"contentVersion must be populated from report detail");
|
||||
|
||||
// One idempotency entry
|
||||
assertEquals(1, idempotencyMapper.selectList().size());
|
||||
|
||||
// Report stats correct
|
||||
PracticeReportDO report = reportMapper.selectById(resp.getReportId());
|
||||
assertEquals(3, report.getQuestionCount());
|
||||
assertEquals(2, report.getAnsweredCount());
|
||||
assertEquals(1, report.getUnansweredCount());
|
||||
assertEquals(1, report.getCorrectCount());
|
||||
assertEquals(1, report.getIncorrectCount());
|
||||
}
|
||||
|
||||
// ========== Replay submit does not double-count wrong questions ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleCountWrongQuestionOnSubmitReplay() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-replay", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
answerQuestion(f.questions.get(1).getId(), "A"); // both wrong
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-replay", 1);
|
||||
PracticeSubmitRespVO first = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(first.getReportId());
|
||||
|
||||
// Replay same submit — idempotent
|
||||
PracticeSubmitRespVO replay = service.submitSession(req, userId, tenantId);
|
||||
assertEquals(first.getReportId(), replay.getReportId());
|
||||
|
||||
// Wrong question count unchanged
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(2, wqs.size());
|
||||
for (WrongQuestionDO wq : wqs) {
|
||||
assertEquals(1, wq.getWrongCount(),
|
||||
"replay must not double-count wrong question: " + wq.getQuestionId());
|
||||
}
|
||||
|
||||
// Still exactly 2 idempotency entries (one per question)
|
||||
assertEquals(2, idempotencyMapper.selectList().size());
|
||||
}
|
||||
|
||||
// ========== Unanswered questions excluded from wrong projection ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForUnanswered() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-unans", 3, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A"); // wrong
|
||||
// questions 2 and 3 unanswered
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-unans", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Only 1 wrong question
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
assertEquals("q-001", wqs.get(0).getQuestionId());
|
||||
}
|
||||
|
||||
// ========== Correct answers excluded from wrong projection ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForCorrectAnswers() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-correct", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "B"); // correct
|
||||
answerQuestion(f.questions.get(1).getId(), "B"); // correct
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-correct", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// No wrong questions
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, wqs.size());
|
||||
}
|
||||
|
||||
// ========== CAS failure on session status: wrong projection guard ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionWhenSessionCasFails() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-cas", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
|
||||
// First submit wins
|
||||
PracticeSubmitReqVO req1 = createSubmitReq(f.sessionId, "idem-proj-cas-1", 1);
|
||||
PracticeSubmitRespVO winner = service.submitSession(req1, userId, tenantId);
|
||||
assertNotNull(winner.getReportId());
|
||||
|
||||
// Second submit with same session + different key → CAS failure (session not ACTIVE)
|
||||
PracticeSubmitReqVO req2 = createSubmitReq(f.sessionId, "idem-proj-cas-2", 1);
|
||||
PracticeSubmitRespVO loser = service.submitSession(req2, userId, tenantId);
|
||||
// The CAS failure path returns the winner's report (existing SUBMITTED session)
|
||||
assertEquals(winner.getReportId(), loser.getReportId(),
|
||||
"CAS loser must get winner's report, not new wrong questions");
|
||||
|
||||
// Wrong question count unchanged — CAS failure prevents new wrong projection
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
assertEquals(1, wqs.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
// ========== wrong_question_id populated in idempotency guard ==========
|
||||
|
||||
@Test
|
||||
void shouldPopulateWrongQuestionIdInIdempotencyGuard() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-wqid", 1, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-wqid", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Verify idempotency row has wrong_question_id populated
|
||||
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
|
||||
assertEquals(1, idems.size());
|
||||
WrongQuestionIdempotencyDO idem = idems.get(0);
|
||||
assertNotNull(idem.getWrongQuestionId(),
|
||||
"wrong_question_id must be populated after upsert");
|
||||
|
||||
// Verify it matches the actual wrong question
|
||||
WrongQuestionDO wq = wrongQuestionMapper.selectById(idem.getWrongQuestionId());
|
||||
assertNotNull(wq);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -53,6 +54,9 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
@MockitoBean
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
package cn.iocoder.yudao.module.education.service.wrong;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* WrongQuestionService test — real DB (H2) backing all wrong question assertions.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Import(WrongQuestionServiceImpl.class)
|
||||
public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionMapper wrongQuestionMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionIdempotencyMapper idempotencyMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeSessionMapper sessionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeQuestionMapper questionMapper;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
session.setCollectionId("col-001");
|
||||
session.setVersion(1);
|
||||
sessionMapper.insert(session);
|
||||
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
q.setDifficulty("easy");
|
||||
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
q.setCorrectAnswer(correctAnswer);
|
||||
q.setExplanation("Explanation " + (i + 1));
|
||||
q.setIsAnswered(false);
|
||||
questions.add(q);
|
||||
}
|
||||
questionMapper.insertBatch(questions);
|
||||
|
||||
return new SessionFixture(session.getId(), questions, session.getVersion());
|
||||
}
|
||||
|
||||
private PracticeReportDetailDO createReportDetail(PracticeQuestionDO q, Long reportId, Long sessionId,
|
||||
String selectedAnswer, boolean isCorrect) {
|
||||
PracticeReportDetailDO detail = new PracticeReportDetailDO();
|
||||
detail.setTenantId(tenantId);
|
||||
detail.setUserId(userId);
|
||||
detail.setReportId(reportId);
|
||||
detail.setSessionId(sessionId);
|
||||
detail.setQuestionId(q.getQuestionId());
|
||||
detail.setSequence(q.getSequence());
|
||||
detail.setStem(q.getStem());
|
||||
detail.setType(q.getType());
|
||||
detail.setDifficulty(q.getDifficulty());
|
||||
detail.setSelectedAnswer(selectedAnswer);
|
||||
detail.setCorrectAnswer(q.getCorrectAnswer());
|
||||
detail.setIsCorrect(isCorrect);
|
||||
detail.setExplanation(q.getExplanation());
|
||||
detail.setOptions(q.getOptions());
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ========== First wrong insert ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateWrongQuestionOnFirstWrongAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-first-wrong", 3, "B");
|
||||
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(1), 1L, f.sessionId, "B", true);
|
||||
PracticeReportDetailDO detail3 = createReportDetail(f.questions.get(2), 1L, f.sessionId, null, false);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId,
|
||||
List.of(detail1, detail2, detail3));
|
||||
|
||||
// Only question 1 is wrong (answered "A", correct is "B")
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
WrongQuestionDO wq = all.get(0);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
assertEquals("B", wq.getLatestCorrectAnswer());
|
||||
assertEquals(1, wq.getWrongCount());
|
||||
assertNotNull(wq.getFirstWrongTime());
|
||||
assertNotNull(wq.getLastWrongTime());
|
||||
assertEquals("PENDING", wq.getMasterStatus());
|
||||
}
|
||||
|
||||
// ========== Repeated wrong increments ==========
|
||||
|
||||
@Test
|
||||
void shouldIncrementWrongCountOnRepeatedWrongAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-repeat", 1, "B");
|
||||
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail1));
|
||||
|
||||
// Same question, different report
|
||||
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(0), 2L, f.sessionId, "C", false);
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2L, f.sessionId, List.of(detail2));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(2, all.get(0).getWrongCount());
|
||||
// Last wrong time should be updated, stem snapshots from latest detail
|
||||
assertEquals("Question 1", all.get(0).getStem());
|
||||
assertEquals(2L, all.get(0).getLastReportId().longValue());
|
||||
}
|
||||
|
||||
// ========== Submit replay no double increment ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleIncrementOnReplay() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-replay-wq", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
|
||||
// First upsert
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
// Same (question, report) second time — idempotency skips
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(1, all.get(0).getWrongCount());
|
||||
|
||||
// Idempotency table has exactly 1 entry for this (question, report)
|
||||
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
|
||||
assertEquals(1, idems.size());
|
||||
}
|
||||
|
||||
// ========== Concurrent submit no double count ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleCountUnderConcurrentUpsert() throws Exception {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-conc-wq", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
ready.countDown();
|
||||
go.await();
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
successCount.incrementAndGet();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
};
|
||||
|
||||
Thread t1 = new Thread(task);
|
||||
Thread t2 = new Thread(task);
|
||||
t1.start();
|
||||
t2.start();
|
||||
ready.await();
|
||||
go.countDown();
|
||||
t1.join(10000);
|
||||
t2.join(10000);
|
||||
|
||||
// Both threads completed without exception (idempotency handles the race)
|
||||
assertEquals(2, successCount.get());
|
||||
|
||||
// Exactly one wrong question with count = 1
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(1, all.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
// ========== Unanswered excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldExcludeUnansweredQuestions() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-unans", 1, "B");
|
||||
// isCorrect=false but selectedAnswer is null (unanswered) → should NOT be counted as wrong
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, null, false);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, all.size());
|
||||
}
|
||||
|
||||
// ========== Correct answer excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForCorrectAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-correct", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "B", true);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, all.size());
|
||||
}
|
||||
|
||||
// ========== Mastery idempotent ==========
|
||||
|
||||
@Test
|
||||
void shouldMarkMasteredIdempotently() {
|
||||
// Manually insert a wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-master");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Mark mastered
|
||||
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("MASTERED", reloaded.getMasterStatus());
|
||||
assertNotNull(reloaded.getMasteredTime());
|
||||
assertEquals(3, reloaded.getWrongCount()); // count preserved
|
||||
|
||||
// Mark mastered again — idempotent
|
||||
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
|
||||
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("MASTERED", again.getMasterStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnmarkMasteredIdempotently() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-unmaster");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setWrongCount(2);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(1));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus());
|
||||
assertNull(reloaded.getMasteredTime());
|
||||
assertEquals(2, reloaded.getWrongCount()); // count preserved
|
||||
|
||||
// Unmark again — idempotent
|
||||
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
|
||||
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", again.getMasterStatus());
|
||||
}
|
||||
|
||||
// ========== Cross ownership ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectAccessForDifferentUser() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Different user tries to get detail
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), 999L, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
|
||||
// Different user tries to mark mastered
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.markMastered(wq.getId(), 999L, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectForDifferentTenant() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross-tenant");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, 999L),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ========== Page bounds ==========
|
||||
|
||||
@Test
|
||||
void shouldPaginateWrongQuestions() {
|
||||
// Insert 5 wrong questions
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-page-" + String.format("%03d", i));
|
||||
wq.setStem("Question page " + i);
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusHours(i));
|
||||
wq.setWrongCount(i);
|
||||
wq.setMasterStatus(i % 2 == 0 ? "MASTERED" : "PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
}
|
||||
|
||||
// Page 1, size 3 — no filter
|
||||
PageResult<WrongQuestionPageItemRespVO> page1 = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 3, null);
|
||||
assertEquals(5, page1.getTotal());
|
||||
assertEquals(3, page1.getList().size());
|
||||
|
||||
// Page 2, size 3
|
||||
PageResult<WrongQuestionPageItemRespVO> page2 = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 2, 3, null);
|
||||
assertEquals(2, page2.getList().size());
|
||||
|
||||
// Filter by PENDING
|
||||
PageResult<WrongQuestionPageItemRespVO> pending = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 10, "PENDING");
|
||||
assertEquals(3, pending.getTotal());
|
||||
|
||||
// Filter by MASTERED
|
||||
PageResult<WrongQuestionPageItemRespVO> mastered = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 10, "MASTERED");
|
||||
assertEquals(2, mastered.getTotal());
|
||||
|
||||
// Different user — empty
|
||||
PageResult<WrongQuestionPageItemRespVO> other = wrongQuestionService.getWrongQuestionPage(
|
||||
999L, tenantId, 1, 10, null);
|
||||
assertEquals(0, other.getTotal());
|
||||
}
|
||||
|
||||
// ========== Review session: ownership + safe snapshot ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateReviewSessionFromOwnWrongQuestions() {
|
||||
// Insert 2 wrong questions
|
||||
for (int i = 1; i <= 2; i++) {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-review-" + i);
|
||||
wq.setStem("Review question " + i);
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Explanation " + i);
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(i);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
}
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-review-1");
|
||||
req.setWrongQuestionIds(List.of(
|
||||
wrongQuestionMapper.selectList().get(0).getId(),
|
||||
wrongQuestionMapper.selectList().get(1).getId()));
|
||||
|
||||
PracticeSessionRespVO resp = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
|
||||
assertNotNull(resp.getSessionId());
|
||||
assertEquals("ACTIVE", resp.getStatus());
|
||||
assertEquals(2, resp.getQuestionCount());
|
||||
assertEquals(2, resp.getQuestions().size());
|
||||
|
||||
// Verify questions are safe — no correct answer exposed
|
||||
for (var q : resp.getQuestions()) {
|
||||
assertNotNull(q.getStem());
|
||||
assertNotNull(q.getOptions());
|
||||
assertTrue(q.getOptions().size() > 0);
|
||||
// correctAnswer and explanation are NOT on PracticeQuestionRespVO (structural guarantee)
|
||||
}
|
||||
|
||||
// Verify idempotent replay
|
||||
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertEquals(resp.getSessionId(), replay.getSessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionWithOtherUsersWrongQuestions() {
|
||||
// Insert wrong question for user 100
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-other-owner");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-cross-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// Different user tries to create review
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
|
||||
WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
|
||||
}
|
||||
|
||||
// ========== DuplicateKeyException on clientSessionId race ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleClientSessionIdRaceInReviewSession() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-race");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-race-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// Create first session
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Same clientSessionId → should return existing (idempotent)
|
||||
PracticeSessionRespVO second = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertEquals(first.getSessionId(), second.getSessionId());
|
||||
}
|
||||
|
||||
// ========== Detail: includes correct answer and explanation ==========
|
||||
|
||||
@Test
|
||||
void shouldExposeCorrectAnswerInDetail() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-detail");
|
||||
wq.setStem("Detail question");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("hard");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v2");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Because B is correct");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(2);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionDetailRespVO detail = wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, tenantId);
|
||||
|
||||
assertEquals("q-detail", detail.getQuestionId());
|
||||
assertEquals("Detail question", detail.getStem());
|
||||
assertEquals("hard", detail.getDifficulty());
|
||||
assertEquals("B", detail.getCorrectAnswer());
|
||||
assertEquals("Because B is correct", detail.getExplanation());
|
||||
assertEquals(2, detail.getWrongCount());
|
||||
assertNotNull(detail.getFirstWrongTime());
|
||||
assertNotNull(detail.getLastWrongTime());
|
||||
}
|
||||
|
||||
|
||||
// ========== New wrong after MASTERED reopens PENDING ==========
|
||||
|
||||
@Test
|
||||
void shouldReopenPendingAfterMasteredOnNewWrong() {
|
||||
// Insert a MASTERED wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-reopen");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Old explanation");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Now submit a new wrong answer for the same question (new report)
|
||||
SessionFixture f = createSessionWithQuestions("uuid-reopen", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 999L, f.sessionId, "A", false);
|
||||
detail.setQuestionId("q-reopen"); // override to match existing wrong question
|
||||
detail.setContentVersion("v2");
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 999L, f.sessionId, List.of(detail));
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus(),
|
||||
"new wrong after MASTERED must reopen PENDING");
|
||||
assertNull(reloaded.getMasteredTime(),
|
||||
"mastered_time must be cleared on new wrong");
|
||||
assertEquals(4, reloaded.getWrongCount(),
|
||||
"wrong_count must increment (3→4)");
|
||||
assertEquals("v2", reloaded.getContentVersion(),
|
||||
"contentVersion must be updated from new report detail");
|
||||
assertEquals(999L, reloaded.getLastReportId().longValue());
|
||||
}
|
||||
|
||||
// ========== Replay same report does NOT reopen/double-count ==========
|
||||
|
||||
@Test
|
||||
void shouldNotReopenOrDoubleCountOnReplayAfterMastered() {
|
||||
// Insert MASTERED question, then submit once → PENDING + count=4
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-replay-after-mastered");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
SessionFixture f = createSessionWithQuestions("uuid-replay-mastered", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1001L, f.sessionId, "A", false);
|
||||
detail.setQuestionId("q-replay-after-mastered");
|
||||
detail.setContentVersion("v2");
|
||||
|
||||
// First upsert: reopens PENDING, count 4
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
|
||||
|
||||
// Replay same report: idempotency guard blocks → count stays 4, status stays PENDING
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus());
|
||||
assertEquals(4, reloaded.getWrongCount(),
|
||||
"replay must not double-count");
|
||||
assertNull(reloaded.getMasteredTime());
|
||||
}
|
||||
|
||||
// ========== Review session fingerprint mismatch ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionWithDifferentIdSet() {
|
||||
// Insert 2 wrong questions
|
||||
WrongQuestionDO wq1 = new WrongQuestionDO();
|
||||
wq1.setTenantId(tenantId); wq1.setUserId(userId);
|
||||
wq1.setQuestionId("q-fp-1"); wq1.setStem("Q1"); wq1.setType("choice");
|
||||
wq1.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq1.setContentVersion("v1"); wq1.setFirstWrongTime(LocalDateTime.now());
|
||||
wq1.setLastWrongTime(LocalDateTime.now()); wq1.setWrongCount(1);
|
||||
wq1.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq1);
|
||||
|
||||
WrongQuestionDO wq2 = new WrongQuestionDO();
|
||||
wq2.setTenantId(tenantId); wq2.setUserId(userId);
|
||||
wq2.setQuestionId("q-fp-2"); wq2.setStem("Q2"); wq2.setType("choice");
|
||||
wq2.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq2.setContentVersion("v1"); wq2.setFirstWrongTime(LocalDateTime.now());
|
||||
wq2.setLastWrongTime(LocalDateTime.now()); wq2.setWrongCount(1);
|
||||
wq2.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq2);
|
||||
|
||||
// Create session with IDs [wq1.id, wq2.id]
|
||||
WrongQuestionReviewReqVO req1 = new WrongQuestionReviewReqVO();
|
||||
req1.setClientSessionId("uuid-fp-mismatch");
|
||||
req1.setWrongQuestionIds(List.of(wq1.getId(), wq2.getId()));
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req1, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Replay with different ID set [wq1.id only] → mismatch
|
||||
WrongQuestionReviewReqVO req2 = new WrongQuestionReviewReqVO();
|
||||
req2.setClientSessionId("uuid-fp-mismatch");
|
||||
req2.setWrongQuestionIds(List.of(wq1.getId()));
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req2, userId, tenantId),
|
||||
SESSION_IDEMPOTENCY_MISMATCH);
|
||||
|
||||
// Replay with same IDs but different order — fingerprint sorts, so same session returned
|
||||
WrongQuestionReviewReqVO req3 = new WrongQuestionReviewReqVO();
|
||||
req3.setClientSessionId("uuid-fp-mismatch");
|
||||
req3.setWrongQuestionIds(List.of(wq2.getId(), wq1.getId())); // reversed order
|
||||
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req3, userId, tenantId);
|
||||
assertEquals(first.getSessionId(), replay.getSessionId(), "sorted fingerprint must match");
|
||||
}
|
||||
|
||||
// ========== Cross-user review session idempotency ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionReplayWithDifferentUser() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId); wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross-user-session"); wq.setStem("Q"); wq.setType("choice");
|
||||
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-cross-user-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// User 100 creates session
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Different user tries to replay same clientSessionId → not found
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
|
||||
SESSION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ========== Concurrent review session create race ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleConcurrentReviewSessionCreateRace() throws Exception {
|
||||
// Insert wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId); wq.setUserId(userId);
|
||||
wq.setQuestionId("q-conc-review"); wq.setStem("Q"); wq.setType("choice");
|
||||
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-conc-review-race");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicReference<PracticeSessionRespVO> r1 = new AtomicReference<>();
|
||||
AtomicReference<PracticeSessionRespVO> r2 = new AtomicReference<>();
|
||||
|
||||
Thread t1 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r1.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
Thread t2 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r2.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
|
||||
t1.start(); t2.start();
|
||||
ready.await(); go.countDown();
|
||||
t1.join(10000); t2.join(10000);
|
||||
|
||||
assertNotNull(r1.get(), "thread 1 must get a response");
|
||||
assertNotNull(r2.get(), "thread 2 must get a response");
|
||||
assertEquals(r1.get().getSessionId(), r2.get().getSessionId(),
|
||||
"concurrent same-fingerprint creates must converge on one session");
|
||||
|
||||
// Only one session created
|
||||
List<PracticeSessionDO> sessions = sessionMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PracticeSessionDO>()
|
||||
.eq(PracticeSessionDO::getClientSessionId, "uuid-conc-review-race"));
|
||||
assertEquals(1, sessions.size());
|
||||
}
|
||||
|
||||
// ========== contentVersion population ==========
|
||||
|
||||
@Test
|
||||
void shouldPopulateContentVersionFromReportDetail() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-contentver", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 2001L, f.sessionId, "A", false);
|
||||
detail.setContentVersion("v3-custom");
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2001L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals("v3-custom", all.get(0).getContentVersion(),
|
||||
"wrong question contentVersion must come from report detail");
|
||||
|
||||
// Detail view exposes contentVersion
|
||||
WrongQuestionDetailRespVO detailVO = wrongQuestionService.getWrongQuestionDetail(
|
||||
all.get(0).getId(), userId, tenantId);
|
||||
assertEquals("v3-custom", detailVO.getContentVersion());
|
||||
}
|
||||
// ========== Empty detail list ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyDetailList() {
|
||||
// Should not throw
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, 1L, List.of());
|
||||
assertEquals(0, wrongQuestionMapper.selectList().size());
|
||||
}
|
||||
|
||||
// ========== Wrong question not found ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowOnNotFound() {
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(99999L, userId, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
DELETE FROM education_wrong_question_idempotency;
|
||||
DELETE FROM education_wrong_question;
|
||||
|
||||
DELETE FROM education_practice_report_detail;
|
||||
DELETE FROM education_practice_report;
|
||||
DELETE FROM education_submit_idempotency;
|
||||
|
||||
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS "education_practice_session" (
|
||||
"difficulty" VARCHAR(32) DEFAULT NULL,
|
||||
"version" INT NOT NULL DEFAULT 1,
|
||||
"last_client_sequence" INT DEFAULT NULL,
|
||||
"review_fingerprint" VARCHAR(64) DEFAULT NULL,
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
@@ -133,6 +134,7 @@ CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
|
||||
"correct_answer" CLOB DEFAULT NULL,
|
||||
"is_correct" BIT NOT NULL DEFAULT FALSE,
|
||||
"explanation" CLOB DEFAULT NULL,
|
||||
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
|
||||
"creator" VARCHAR(64) DEFAULT '',
|
||||
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" VARCHAR(64) DEFAULT '',
|
||||
@@ -143,3 +145,58 @@ CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_detail_session" ON "education_practice_report_detail" ("session_id");
|
||||
|
||||
-- Ticket #9: add content_version + options snapshot to report detail
|
||||
ALTER TABLE "education_practice_report_detail"
|
||||
ADD COLUMN IF NOT EXISTS "content_version" VARCHAR(64) NOT NULL DEFAULT '';
|
||||
ALTER TABLE "education_practice_report_detail"
|
||||
ADD COLUMN IF NOT EXISTS "options" CLOB DEFAULT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "education_wrong_question" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"question_id" VARCHAR(64) NOT NULL,
|
||||
"stem" CLOB NOT NULL,
|
||||
"type" VARCHAR(32) NOT NULL,
|
||||
"difficulty" VARCHAR(32) DEFAULT NULL,
|
||||
"options" CLOB NOT NULL,
|
||||
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
|
||||
"latest_correct_answer" CLOB DEFAULT NULL,
|
||||
"latest_explanation" CLOB DEFAULT NULL,
|
||||
"first_wrong_time" TIMESTAMP NOT NULL,
|
||||
"last_wrong_time" TIMESTAMP NOT NULL,
|
||||
"wrong_count" INT NOT NULL DEFAULT 1,
|
||||
"master_status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
"mastered_time" TIMESTAMP DEFAULT NULL,
|
||||
"last_report_id" BIGINT DEFAULT NULL,
|
||||
"last_session_id" BIGINT 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_tenant_user_question" UNIQUE ("tenant_id", "user_id", "question_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_wq_tenant_user_status" ON "education_wrong_question" ("tenant_id", "user_id", "master_status");
|
||||
CREATE INDEX IF NOT EXISTS "idx_wq_tenant_user_last_wrong" ON "education_wrong_question" ("tenant_id", "user_id", "last_wrong_time");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "education_wrong_question_idempotency" (
|
||||
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"tenant_id" BIGINT NOT NULL,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"wrong_question_id" BIGINT DEFAULT NULL,
|
||||
"report_id" BIGINT NOT NULL,
|
||||
"question_id" VARCHAR(64) 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_tenant_user_question_report" UNIQUE ("tenant_id", "user_id", "question_id", "report_id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_wq_idem_report" ON "education_wrong_question_idempotency" ("report_id");
|
||||
|
||||
Reference in New Issue
Block a user