feat(education): complete Flyway migration and atomic submit

This commit is contained in:
2026-07-30 12:06:55 +08:00
parent ce02f8acb4
commit 79a5799502
228 changed files with 32551 additions and 1376 deletions

View File

@@ -52,6 +52,17 @@ public class EducationPropertiesTest {
assertTrue(defaults.getPilotTenantIds().isEmpty(), "Pilot 租户为空时不限制租户");
}
@Test
public void localDevelopmentShouldDefaultToFalse() {
EducationProperties defaults = new EducationProperties();
assertFalse(defaults.getTenantResolution().isLocalDevelopmentEnabled());
}
@Test
public void activeProfileShouldNotEnableLocalDevelopment() {
assertFalse(properties.getTenantResolution().isLocalDevelopmentEnabled());
}
@Test
public void testHostnameTenantMapDefaults() {
EducationProperties defaults = new EducationProperties();

View File

@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.education.controller.app;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EducationContextControllerHttpTest {
private MockMvc mockMvc;
private TenantCommonApi tenantCommonApi;
@BeforeEach
void setUp() {
tenantCommonApi = mock(TenantCommonApi.class);
EducationContextController controller = new EducationContextController();
ReflectionTestUtils.setField(controller, "tenantCommonApi", tenantCommonApi);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class)))
.build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
@Test
void anonymousStudentContextIsRejected() throws Exception {
mockMvc.perform(get("/education/context"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void adminPrincipalIsRejected() throws Exception {
setLoginUser(10L, 20L, UserTypeEnum.ADMIN);
TenantContextHolder.setTenantId(20L);
mockMvc.perform(get("/education/context"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void memberPrincipalUsesOnlySecurityAndTenantContexts() throws Exception {
setLoginUser(10L, 999L, UserTypeEnum.MEMBER);
TenantContextHolder.setTenantId(20L);
TenantRespDTO tenant = new TenantRespDTO();
tenant.setId(20L);
tenant.setName("Demo School");
when(tenantCommonApi.getTenant(20L)).thenReturn(tenant);
mockMvc.perform(get("/education/context")
.param("userId", "888")
.param("tenantId", "777"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value(10))
.andExpect(jsonPath("$.data.tenantId").value(20));
verify(tenantCommonApi).validateTenant(20L);
verify(tenantCommonApi).getTenant(20L);
}
private static void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
}

View File

@@ -2,12 +2,15 @@ package cn.iocoder.yudao.module.education.controller.app.catalog;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -39,10 +42,12 @@ class CatalogControllerHttpTest {
private MockMvc mockMvc;
private CatalogProvider catalogProvider;
private QuestionCatalogService questionCatalogService;
@BeforeEach
void setUp() {
catalogProvider = mock(CatalogProvider.class);
questionCatalogService = mock(QuestionCatalogService.class);
when(catalogProvider.isEnabled()).thenReturn(true);
CatalogService catalogService = new CatalogServiceImpl(catalogProvider);
CatalogController controller = new CatalogController();
@@ -50,6 +55,9 @@ class CatalogControllerHttpTest {
var field = CatalogController.class.getDeclaredField("catalogService");
field.setAccessible(true);
field.set(controller, catalogService);
var questionField = CatalogController.class.getDeclaredField("questionCatalogService");
questionField.setAccessible(true);
questionField.set(controller, questionCatalogService);
var accessField = CatalogController.class.getDeclaredField("educationAccessService");
accessField.setAccessible(true);
accessField.set(controller, mock(EducationAccessService.class));
@@ -153,6 +161,24 @@ class CatalogControllerHttpTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
void shouldMapNestedCollectionQuestionsAndReturnSafeFields() throws Exception {
setLoginUser(100L);
SafeQuestionRespVO question = SafeQuestionRespVO.builder()
.id("9").contentVersion("3").stem("题干").type("SINGLE").build();
when(questionCatalogService.listCollectionQuestions("7", "SINGLE", "EASY", 1, 20))
.thenReturn(new PageResult<>(java.util.List.of(question), 1L));
mockMvc.perform(get("/education/catalog/question-collections/7/questions")
.param("type", "SINGLE")
.param("difficulty", "EASY"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.list[0].id").value("9"))
.andExpect(jsonPath("$.data.list[0].correctAnswer").doesNotExist())
.andExpect(jsonPath("$.data.list[0].explanation").doesNotExist())
.andExpect(jsonPath("$.data.list[0].analysis").doesNotExist());
}
// ========== Anonymous → 401 ==========
@Test
@@ -189,9 +215,9 @@ class CatalogControllerHttpTest {
// ========== Query parameter forwarding ==========
@Test
void shouldForwardIncludeHiddenTrue() throws Exception {
void shouldNotExposeHiddenEntriesSwitch() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", "question_bank", true))
when(catalogProvider.listContentEntries("r1", "question_bank", false))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-entries")
@@ -200,12 +226,13 @@ class CatalogControllerHttpTest {
.param("includeHidden", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(catalogProvider).listContentEntries("r1", "question_bank", false);
}
@Test
void shouldForwardIncludeInactiveTrueAndMarkerType() throws Exception {
void shouldNotExposeInactiveNodesSwitch() throws Exception {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", "root", "flat", true, "marker_type"))
when(catalogProvider.listContentNodes("e1", "root", "flat", false, "marker_type"))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/education/catalog/content-nodes")
@@ -216,6 +243,7 @@ class CatalogControllerHttpTest {
.param("markerType", "marker_type"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(catalogProvider).listContentNodes("e1", "root", "flat", false, "marker_type");
}
@Test

View File

@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogProvider;
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
import cn.iocoder.yudao.module.education.service.catalog.CatalogServiceImpl;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -22,6 +23,7 @@ import java.util.Collections;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_DATA_SOURCE_DISABLED;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -51,6 +53,9 @@ class CatalogControllerTest {
@MockitoBean
private CatalogProvider catalogProvider;
@MockitoBean
private QuestionCatalogService questionCatalogService;
@BeforeEach
void setUp() {
when(catalogProvider.isEnabled()).thenReturn(true);
@@ -112,7 +117,7 @@ class CatalogControllerTest {
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
var result = catalogController.listContentEntries("r1", null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@@ -123,7 +128,7 @@ class CatalogControllerTest {
when(catalogProvider.listContentNodes("e1", null, "children", false, null))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "children", false, null);
var result = catalogController.listContentNodes("e1", null, "children", null);
assertNotNull(result);
assertEquals(0, result.getCode());
}
@@ -139,6 +144,18 @@ class CatalogControllerTest {
assertEquals(0, result.getCode());
}
@Test
void shouldForwardNestedCollectionQuestionRequest() {
setLoginUser(100L);
when(questionCatalogService.listCollectionQuestions("7", "SINGLE", "EASY", 2, 10))
.thenReturn(new cn.iocoder.yudao.framework.common.pojo.PageResult<>(Collections.emptyList(), 0L));
var result = catalogController.listCollectionQuestions("7", "SINGLE", "EASY", 2, 10);
assertEquals(0, result.getCode());
verify(questionCatalogService).listCollectionQuestions("7", "SINGLE", "EASY", 2, 10);
}
// ========== 未认证拒绝 ==========
@Test
@@ -160,40 +177,30 @@ class CatalogControllerTest {
void shouldRejectAllEndpointsWhenNotAuthenticated() {
assertThrows(ServiceException.class, () -> catalogController.listSubjects(null, null, null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listModuleNodes(null, null, null));
assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null, false));
assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", false, null));
assertThrows(ServiceException.class, () -> catalogController.listContentEntries(null, null));
assertThrows(ServiceException.class, () -> catalogController.listContentNodes("e1", null, "children", null));
assertThrows(ServiceException.class, () -> catalogController.listQuestionCollections(null, null, null, null, null));
}
// ========== 参数转发 ==========
@Test
void shouldForwardIncludeHiddenTrue() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, true))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, true);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeHiddenFalse() {
setLoginUser(100L);
when(catalogProvider.listContentEntries("r1", null, false))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentEntries("r1", null, false);
var result = catalogController.listContentEntries("r1", null);
assertEquals(0, result.getCode());
}
@Test
void shouldForwardIncludeInactiveTrue() {
void shouldForceVisibleActiveNodes() {
setLoginUser(100L);
when(catalogProvider.listContentNodes("e1", null, "flat", true, "marker"))
when(catalogProvider.listContentNodes("e1", null, "flat", false, "marker"))
.thenReturn(Collections.emptyList());
var result = catalogController.listContentNodes("e1", null, "flat", true, "marker");
var result = catalogController.listContentNodes("e1", null, "flat", "marker");
assertEquals(0, result.getCode());
}

View File

@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
@@ -69,6 +71,7 @@ class PracticeAnswerControllerHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== PUT /answer success ==========
@@ -109,6 +112,7 @@ class PracticeAnswerControllerHttpTest {
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "answer response must not leak correctAnswer");
assertFalse(json.contains("isCorrect"), "answer response must not leak isCorrect");
assertFalse(json.contains("explanation"), "answer response must not leak explanation");
}
// ========== Auth validation ==========
@@ -130,6 +134,19 @@ class PracticeAnswerControllerHttpTest {
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401ForAnswerWhenAdminPrincipal() throws Exception {
setLoginUser(100L, 1L, UserTypeEnum.ADMIN);
mockMvc.perform(put("/education/practice-session/answer")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validReq())))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
verifyNoInteractions(service);
}
// ========== Validation error envelope ==========
@Test
@@ -364,9 +381,15 @@ class PracticeAnswerControllerHttpTest {
// ========== helpers ==========
private void setLoginUser(Long userId, Long tenantId) {
setLoginUser(userId, tenantId, UserTypeEnum.MEMBER);
}
private void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
@@ -74,6 +76,7 @@ class PracticeSessionControllerHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== POST /create ==========
@@ -120,6 +123,23 @@ class PracticeSessionControllerHttpTest {
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturn401ForCreateWhenAdminPrincipal() throws Exception {
setLoginUser(100L, 1L, UserTypeEnum.ADMIN);
PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO();
req.setClientSessionId("uuid-admin");
req.setCollectionId("col-001");
req.setQuestionCount(1);
mockMvc.perform(post("/education/practice-session/create")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
verifyNoInteractions(service);
}
@Test
void shouldReturnIdempotentOnDuplicateClientSessionId() throws Exception {
setLoginUser(100L, 1L);
@@ -295,9 +315,15 @@ class PracticeSessionControllerHttpTest {
// ========== helpers ==========
private void setLoginUser(Long userId, Long tenantId) {
setLoginUser(userId, tenantId, UserTypeEnum.MEMBER);
}
private void setLoginUser(Long userId, Long tenantId, UserTypeEnum userType) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(userType.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -5,6 +5,8 @@ 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.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
@@ -72,6 +74,7 @@ class PracticeSessionControllerSubmitHttpTest {
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
// ========== POST /submit ==========
@@ -292,6 +295,8 @@ class PracticeSessionControllerSubmitHttpTest {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(tenantId);
loginUser.setUserType(UserTypeEnum.MEMBER.getValue());
TenantContextHolder.setTenantId(tenantId);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}

View File

@@ -0,0 +1,334 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.LocalDateTime;
import java.util.stream.Stream;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EducationTenantControllerHttpTest {
private MockMvc mockMvc;
private TenantCommonApi tenantCommonApi;
private EducationTenantController controller;
@BeforeEach
void setUp() {
tenantCommonApi = mock(TenantCommonApi.class);
controller = new EducationTenantController();
ReflectionTestUtils.setField(controller, "tenantCommonApi", tenantCommonApi);
ReflectionTestUtils.setField(controller, "educationProperties", new EducationProperties());
GlobalExceptionHandler exceptionHandler = new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class));
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(exceptionHandler)
.build();
}
@Test
void validProductionOriginReturnsMinimalTenantRoutingData() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(100L, "Demo School"));
mockMvc.perform(get("/education/tenant/resolve")
.header("Origin", "https://School.Example.COM:8443"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").value(""))
.andExpect(jsonPath("$.data.tenantId").value(100))
.andExpect(jsonPath("$.data.displayName").value("Demo School"))
.andExpect(jsonPath("$.data.tenantName").doesNotExist())
.andExpect(jsonPath("$.data.status").doesNotExist())
.andExpect(jsonPath("$.data.loginMethods").doesNotExist());
}
@Test
void refererFallbackResolvesTenant() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(100L, "Demo School"));
expectSuccess(get("/education/tenant/resolve")
.header("Referer", "https://school.example.com/login?next=practice"), 100L, "Demo School");
}
@Test
void forgedSyntacticallyValidOriginIsAcceptedOnlyAsLocatorClaim() throws Exception {
when(tenantCommonApi.getTenantByWebsite("claimed.example.com"))
.thenReturn(availableTenant(101L, "Claimed School"));
expectSuccess(get("/education/tenant/resolve")
.header("Origin", "https://claimed.example.com"), 101L, "Claimed School");
}
@Test
void malformedOriginDoesNotFallBackToReferer() throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "not-a-url")
.header("Referer", "https://school.example.com/login"),
1005001003, "租户识别请求无效");
}
@Test
void malformedOriginIsRejectedBeforeLocalHandlePrecedence() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "not-a-url")
.param("hostname", "localhost")
.param("tenantHandle", "School_01"),
1005001003, "租户识别请求无效");
}
@Test
void productionOriginConflictIsRejectedBeforeLocalHandlePrecedence() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", "localhost")
.param("tenantHandle", "School_01"),
1005001008, "租户识别信息冲突");
}
@Test
void originAndHostnameConflict() throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", "other.example.com"),
1005001008, "租户识别信息冲突");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"school.example.com:", "school.example.com:65536", "school.example.com:abc"
})
void malformedHostnamePortIsInvalid(String hostname) throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", hostname),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"school.example.com?probe", "school.example.com#probe"
})
void hostnameQueryOrFragmentIsInvalidEvenWhenOriginHostMatches(String hostname) throws Exception {
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("hostname", hostname),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"https://school.example.com:", "https://school.example.com:65536", "https://school.example.com:abc"
})
void malformedOriginPortIsInvalid(String origin) throws Exception {
expectFailure(get("/education/tenant/resolve").header("Origin", origin),
1005001003, "租户识别请求无效");
}
@Test
void arbitraryProductionHostnameWithoutBrowserEvidenceIsInvalid() throws Exception {
expectFailure(get("/education/tenant/resolve").param("hostname", "school.example.com"),
1005001003, "租户识别请求无效");
}
@Test
void tenantHandleLookupIsExactCaseSensitiveAndReturnsSystemNameAsDisplayName() throws Exception {
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(102L, "School_01"));
expectSuccess(get("/education/tenant/resolve").param("tenantHandle", "School_01"),
102L, "School_01");
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "school_01"),
1005001004, "当前租户不可用");
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "a"),
1005001003, "租户识别请求无效");
}
@Test
void legacyTenantNameIsRejectedIndependently() throws Exception {
expectFailure(get("/education/tenant/resolve").param("tenantName", "School_01"),
1005001003, "租户识别请求无效");
}
@Test
void domainAndHandleMayAgree() throws Exception {
TenantRespDTO tenant = availableTenant(103L, "School_01");
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(tenant);
when(tenantCommonApi.getTenantByName("School_01")).thenReturn(tenant);
expectSuccess(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("tenantHandle", "School_01"),
103L, "School_01");
}
@Test
void domainAndHandleConflict() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(103L, "Domain School"));
when(tenantCommonApi.getTenantByName("School_01"))
.thenReturn(availableTenant(104L, "School_01"));
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com")
.param("tenantHandle", "School_01"),
1005001008, "租户识别信息冲突");
}
@ParameterizedTest
@MethodSource("unavailableTenants")
void unknownDisabledAndExpiredHaveIdenticalWireShape(TenantRespDTO tenant) throws Exception {
when(tenantCommonApi.getTenantByName("Unavailable_01")).thenReturn(tenant);
expectFailure(get("/education/tenant/resolve").param("tenantHandle", "Unavailable_01"),
1005001004, "当前租户不可用");
}
@ParameterizedTest
@MethodSource("normalizedOrigins")
void hostIdentityIsCanonicalHostOnly(String origin, String canonicalHost) throws Exception {
when(tenantCommonApi.getTenantByWebsite(canonicalHost))
.thenReturn(availableTenant(105L, "Normalized School"));
expectSuccess(get("/education/tenant/resolve").header("Origin", origin),
105L, "Normalized School");
}
@Test
void localHostnameIsRejectedWhenFlagIsFalse() throws Exception {
expectFailure(get("/education/tenant/resolve").param("hostname", "localhost:48080"),
1005001003, "租户识别请求无效");
}
@ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"http://127.0.0.1:48080", "http://[::1]:48080"
})
void localOriginIsRejectedWhenFlagIsFalse(String origin) throws Exception {
expectFailure(get("/education/tenant/resolve").header("Origin", origin),
1005001003, "租户识别请求无效");
}
@Test
void explicitLocalFlagPermitsConfiguredCodeLessHost() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("localhost", "Local_01");
when(tenantCommonApi.getTenantByName("Local_01"))
.thenReturn(availableTenant(106L, "Local_01"));
expectSuccess(get("/education/tenant/resolve").param("hostname", "localhost:48080"),
106L, "Local_01");
}
@Test
void explicitLocalFlagPermitsConfiguredLoopbackOrigin() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("::1", "Local_01");
when(tenantCommonApi.getTenantByName("Local_01"))
.thenReturn(availableTenant(106L, "Local_01"));
expectSuccess(get("/education/tenant/resolve").header("Origin", "http://[::1]:48080"),
106L, "Local_01");
}
@Test
void explicitHandlePrecedesLocalHostname() throws Exception {
EducationProperties properties = properties();
properties.getTenantResolution().setLocalDevelopmentEnabled(true);
properties.getHostnameTenantMap().put("localhost", "Local_01");
when(tenantCommonApi.getTenantByName("Remote_01"))
.thenReturn(availableTenant(107L, "Remote_01"));
expectSuccess(get("/education/tenant/resolve")
.param("hostname", "localhost:48080")
.param("tenantHandle", "Remote_01"),
107L, "Remote_01");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never()).getTenantByName("Local_01");
}
@Test
void productionDomainDoesNotUseEducationHostnameMap() throws Exception {
properties().getHostnameTenantMap().put("school.example.com", "Mapped_01");
when(tenantCommonApi.getTenantByWebsite("school.example.com"))
.thenReturn(availableTenant(108L, "Canonical School"));
expectSuccess(get("/education/tenant/resolve").header("Origin", "https://school.example.com"),
108L, "Canonical School");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never()).getTenantByName("Mapped_01");
}
@Test
void nonCanonicalStoredWebsiteIsUnavailable() throws Exception {
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(null);
expectFailure(get("/education/tenant/resolve")
.header("Origin", "https://school.example.com"),
1005001004, "当前租户不可用");
org.mockito.Mockito.verify(tenantCommonApi).getTenantByWebsite("school.example.com");
org.mockito.Mockito.verify(tenantCommonApi, org.mockito.Mockito.never())
.getTenantByWebsite("https://school.example.com");
}
private EducationProperties properties() {
return (EducationProperties) ReflectionTestUtils.getField(controller, "educationProperties");
}
private void expectSuccess(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request,
Long tenantId, String displayName) throws Exception {
mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").value(""))
.andExpect(jsonPath("$.data.tenantId").value(tenantId))
.andExpect(jsonPath("$.data.displayName").value(displayName))
.andExpect(jsonPath("$.data", org.hamcrest.Matchers.aMapWithSize(2)));
}
private void expectFailure(org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder request,
int code, String message) throws Exception {
mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(code))
.andExpect(jsonPath("$.msg").value(message))
.andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$", org.hamcrest.Matchers.aMapWithSize(3)));
}
private static Stream<TenantRespDTO> unavailableTenants() {
TenantRespDTO disabled = availableTenant(108L, "Unavailable_01");
disabled.setStatus(CommonStatusEnum.DISABLE.getStatus());
TenantRespDTO expired = availableTenant(109L, "Unavailable_01");
expired.setExpireTime(LocalDateTime.now().minusDays(1));
return Stream.of(null, disabled, expired);
}
private static Stream<org.junit.jupiter.params.provider.Arguments> normalizedOrigins() {
return Stream.of(
org.junit.jupiter.params.provider.Arguments.of("https://School.Example.COM.:443", "school.example.com"),
org.junit.jupiter.params.provider.Arguments.of("https://school.example.com:8443", "school.example.com")
);
}
private static TenantRespDTO availableTenant(Long id, String name) {
TenantRespDTO tenant = new TenantRespDTO();
tenant.setId(id);
tenant.setName(name);
tenant.setStatus(CommonStatusEnum.ENABLE.getStatus());
tenant.setExpireTime(LocalDateTime.now().plusDays(1));
return tenant;
}
}

View File

@@ -1,87 +0,0 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link EducationTenantController} 的单元测试
*
* @author 恭学教育
*/
class EducationTenantControllerTest {
// ========== normalizeHostname ==========
@ParameterizedTest
@NullAndEmptySource
void normalizeHostname_shouldReturnNullForBlankInput(String hostname) {
assertNull(EducationTenantController.normalizeHostname(hostname));
}
@Test
void normalizeHostname_shouldLowercase() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname("School.Example.COM"));
}
@Test
void normalizeHostname_shouldTrimWhitespace() {
assertEquals("school.example.com",
EducationTenantController.normalizeHostname(" school.example.com "));
}
@Test
void normalizeHostname_shouldKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("school.example.com:8080"));
}
@Test
void normalizeHostname_shouldLowercaseAndKeepPort() {
assertEquals("school.example.com:8080",
EducationTenantController.normalizeHostname("School.Example.COM:8080"));
}
@Test
void normalizeHostname_shouldRejectProtocol() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("http://school.example.com"));
}
@Test
void normalizeHostname_shouldRejectPath() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("school.example.com/path"));
}
@Test
void normalizeHostname_shouldRejectProtocolWithPort() {
assertThrows(RuntimeException.class, () ->
EducationTenantController.normalizeHostname("https://school.example.com:8443"));
}
@Test
void normalizeHostname_shouldRejectOversizedPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("school.example.com:999999999999"));
}
@Test
void normalizeHostname_shouldKeepIPv6Colons() {
// A bare IPv6 address like ::1: the port-stripping should be safe
// because last colon is followed by digits -> treated as port
// We'll just check no exception thrown
assertDoesNotThrow(() -> EducationTenantController.normalizeHostname("[::1]"));
}
@Test
void normalizeHostname_shouldRejectNonNumericPort() {
assertThrows(RuntimeException.class,
() -> EducationTenantController.normalizeHostname("host:name"));
}
}

View File

@@ -1,197 +0,0 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link EducationTenantController} 的集成测试 — 测试租户解析流程
*
* @author 恭学教育
*/
@SpringBootTest(classes = EducationTenantResolveIntegrationTest.Config.class,
properties = {
"yudao.education.enabled=true",
"yudao.education.version=1.0.0-test"
},
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("unit-test")
class EducationTenantResolveIntegrationTest {
@Configuration
@EnableConfigurationProperties(EducationProperties.class)
@Import(EducationTenantController.class)
static class Config {
}
@Resource
private EducationTenantController controller;
@MockitoBean
private TenantCommonApi tenantCommonApi;
// ========== happy path ==========
@Test
void resolve_byTenantName_shouldReturnActiveTenant() {
TenantRespDTO dto = buildTenant(100L, "demo-school", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("demo-school")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve(null, "demo-school");
assertTrue(result.isSuccess());
EducationTenantRespVO data = result.getData();
assertEquals(100L, data.getTenantId());
assertEquals("demo-school", data.getTenantName());
assertEquals("ACTIVE", data.getStatus());
assertTrue(data.getLoginMethods().contains("PASSWORD"));
assertTrue(data.getLoginMethods().contains("SMS"));
}
@Test
void resolve_byHostname_shouldFindByWebsite() {
TenantRespDTO dto = buildTenant(200L, "school-alpha", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("school.example.com", null);
assertTrue(result.isSuccess());
assertEquals(200L, result.getData().getTenantId());
assertEquals("school-alpha", result.getData().getTenantName());
}
@Test
void resolve_byHostname_shouldNormalizeCaseAndKeepPort() {
TenantRespDTO dto = buildTenant(300L, "school-beta", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByWebsite("school.example.com:8443")).thenReturn(dto);
CommonResult<EducationTenantRespVO> result = controller.resolve("School.Example.COM:8443", null);
assertTrue(result.isSuccess());
assertEquals(300L, result.getData().getTenantId());
}
@Test
void resolve_conflictingHostnameAndTenantName_shouldFail() {
TenantRespDTO byName = buildTenant(600L, "school-a", CommonStatusEnum.ENABLE.getStatus());
TenantRespDTO byHost = buildTenant(601L, "school-b", CommonStatusEnum.ENABLE.getStatus());
when(tenantCommonApi.getTenantByName("school-a")).thenReturn(byName);
when(tenantCommonApi.getTenantByWebsite("school-b.example.com")).thenReturn(byHost);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> controller.resolve("school-b.example.com", "school-a"));
assertTrue(exception.getMessage().contains("指向不同租户"));
}
// ========== error cases ==========
@Test
void resolve_noInput_shouldFail() {
try {
controller.resolve(null, null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_blankInput_shouldFail() {
try {
controller.resolve(" ", "");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("不能同时为空"));
}
}
@Test
void resolve_notFound_shouldFail() {
when(tenantCommonApi.getTenantByName("nonexistent")).thenReturn(null);
when(tenantCommonApi.getTenantByWebsite(anyString())).thenReturn(null);
try {
controller.resolve(null, "nonexistent");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("租户不存在"));
}
}
@Test
void resolve_disabled_shouldFail() {
TenantRespDTO dto = buildTenant(400L, "disabled-school", CommonStatusEnum.DISABLE.getStatus());
when(tenantCommonApi.getTenantByName("disabled-school")).thenReturn(dto);
try {
controller.resolve(null, "disabled-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("已被禁用"));
}
}
@Test
void resolve_expired_shouldFail() {
TenantRespDTO dto = buildTenant(500L, "expired-school", CommonStatusEnum.ENABLE.getStatus());
dto.setExpireTime(LocalDateTime.now().minusDays(1));
when(tenantCommonApi.getTenantByName("expired-school")).thenReturn(dto);
try {
controller.resolve(null, "expired-school");
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("当前租户不可用"));
}
}
@Test
void resolve_protocolInHostname_shouldFail() {
try {
controller.resolve("http://evil.com", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("协议"));
}
}
@Test
void resolve_pathInHostname_shouldFail() {
try {
controller.resolve("school.com/admin", null);
fail("Expected exception");
} catch (RuntimeException e) {
assertTrue(e.getMessage().contains("路径"));
}
}
// ========== helpers ==========
private static TenantRespDTO buildTenant(Long id, String name, Integer status) {
TenantRespDTO dto = new TenantRespDTO();
dto.setId(id);
dto.setName(name);
dto.setStatus(status);
dto.setWebsites(Collections.singletonList(name + ".example.com"));
dto.setExpireTime(LocalDateTime.now().plusYears(1));
return dto;
}
}

View File

@@ -1,10 +1,12 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DuplicateKeyException;
@@ -18,12 +20,12 @@ import static org.junit.jupiter.api.Assertions.*;
* @author 恭学教育
*/
@Import({})
public class PracticeSessionMapperTest extends BaseDbUnitTest {
public class PracticeSessionMapperTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
// ========== Session CRUD + uniqueness ==========
@@ -160,7 +162,8 @@ public class PracticeSessionMapperTest extends BaseDbUnitTest {
PracticeQuestionDO loadedQ = loaded.get(0);
assertEquals("What is 1+1?", loadedQ.getStem());
assertEquals("v1", loadedQ.getContentVersion());
assertEquals(optionsJson, loadedQ.getOptions());
assertEquals(JsonUtils.parseArray(optionsJson, Object.class),
JsonUtils.parseArray(loadedQ.getOptions(), Object.class));
assertFalse(loadedQ.getIsAnswered());
}

View File

@@ -104,19 +104,42 @@ class ScalarAutoConfigurationTest {
ex.getCode());
}
// ========== JAVA_READ (unsupported) ==========
// ========== JAVA_READ (native catalog) ==========
@Test
void shouldCreateUnsupportedProviderForJavaReadMode() {
void shouldCreateJavaProviderForJavaReadMode() {
contextRunner
.withPropertyValues(
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ")
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.RegionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.RegionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.SchoolMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.SchoolMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.MajorMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.MajorMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.SubjectMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.SubjectMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.PracticeBlueprintMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.PracticeBlueprintMapper.class))
.withBean(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionQuestionMapper.class,
() -> org.mockito.Mockito.mock(cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionCollectionQuestionMapper.class))
.withUserConfiguration(cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class)
.run(context -> {
assertThat(context).hasSingleBean(CatalogProvider.class);
CatalogProvider provider = context.getBean(CatalogProvider.class);
assertThat(provider).isInstanceOf(UnsupportedModeCatalogProvider.class);
assertFalse(provider.isEnabled());
assertThat(provider).isInstanceOf(cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class);
assertTrue(provider.isEnabled());
});
}
@@ -190,21 +213,23 @@ class ScalarAutoConfigurationTest {
// ========== Fix #5: QuestionCatalogProvider wiring for JAVA_READ ==========
@Test
void shouldExposeUnsupportedQuestionCatalogProviderForJavaRead() {
void shouldExposeJavaQuestionCatalogProviderForJavaRead() {
// JavaCatalogProvider is a @Component — needs all mapper beans to be created.
// Without mappers the context start fails; we test the unsupported fallback path instead.
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());
// JavaCatalogProvider won't start without mappers — no QuestionCatalogProvider bean
assertThat(context).doesNotHaveBean(QuestionCatalogProvider.class);
});
}
@Test
void shouldStartQuestionCatalogServiceInJavaReadMode() {
// JavaCatalogProvider needs real mappers; this test verifies the context
// gracefully reports the missing dependency rather than silently providing wrong bean.
contextRunner
.withUserConfiguration(
cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl.class)
@@ -212,10 +237,9 @@ class ScalarAutoConfigurationTest {
"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);
// Without mappers, JavaCatalogProvider can't be created;
// QuestionCatalogServiceImpl has no QuestionCatalogProvider to inject.
assertThat(context).getFailure().isNotNull();
});
}

View File

@@ -71,13 +71,13 @@ class CatalogServiceImplTest {
}
@Test
void shouldReturnEmptyListWhenProviderReturnsNull() {
void shouldFailClosedWhenProviderReturnsNull() {
when(catalogProvider.listRegions()).thenReturn(null);
List<CatalogRegionRespVO> result = catalogService.listRegions();
ServiceException ex = assertThrows(ServiceException.class, () -> catalogService.listRegions());
assertNotNull(result);
assertTrue(result.isEmpty());
assertEquals(cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_UNAVAILABLE.getCode(),
ex.getCode());
}
@Test

View File

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.education.service.favorite;
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.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.EducationFavoriteDO;
@@ -26,12 +26,12 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* FavoriteService test — real DB (H2) backing all favorite assertions.
* FavoriteService test — real PostgreSQL backing all favorite assertions.
*
* @author 恭学教育
*/
@Import(FavoriteServiceImpl.class)
public class FavoriteServiceImplTest extends BaseDbUnitTest {
public class FavoriteServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private FavoriteService favoriteService;

View File

@@ -1,19 +1,21 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
import cn.iocoder.yudao.module.education.dal.dataobject.IdempotencyDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.dal.mysql.AnswerIdempotencyMapper;
import cn.iocoder.yudao.module.education.dal.mysql.IdempotencyStoreMapper;
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.question.QuestionContentSafety;
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;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
@@ -25,14 +27,14 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* PracticeAnswerService test — real DB (H2) backing all persistence assertions.
* PracticeAnswerService test — real PostgreSQL backing all persistence assertions.
* Covers findings #1-#6: conditional update, atomic CAS, session-wide sequence,
* fail-closed options, version null/overflow, idempotency replay, concurrency.
*
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class})
public class PracticeAnswerServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -40,11 +42,11 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource
private AnswerIdempotencyMapper idempotencyMapper;
private IdempotencyStoreMapper idempotencyMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@@ -78,7 +80,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
question.setContentVersion("v1");
question.setStem("test stem");
question.setType("choice");
question.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0}]");
question.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
question.setIsAnswered(false);
questionMapper.insert(question);
@@ -130,7 +132,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals(1, resumed.getLastClientSequence());
// Idempotency record committed
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-persist");
assertNotNull(idem);
assertEquals("ACCEPTED", idem.getStatus());
@@ -151,7 +153,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals(first.getSelectedAnswer(), second.getSelectedAnswer());
// Exactly one idempotency record
List<AnswerIdempotencyDO> records = idempotencyMapper.selectList();
List<IdempotencyDO> records = idempotencyMapper.selectList();
assertEquals(1, records.stream()
.filter(r -> "idem-replay".equals(r.getIdempotencyKey())).count());
}
@@ -260,9 +262,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
sessionMapper.insert(session);
PracticeQuestionDO q1 = createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
PracticeQuestionDO q2 = createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
// Answer Q1 with seq=10
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 10, 1);
@@ -299,9 +301,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
sessionMapper.insert(session);
createQuestion(session.getId(), 1, "q-001",
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
createQuestion(session.getId(), 2, "q-002",
"[{\"label\":\"B\",\"content\":\"Ans B\",\"order\":1.0}]");
"[{\"label\":\"A\",\"content\":\"Ans A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Ans B\",\"order\":2.0}]");
// Answer Q1 with seq=5
PracticeAnswerReqVO req1 = makeReq(session.getId(), 1, "A", "idem-q1", 5, 1);
@@ -381,7 +383,7 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
ANSWER_STALE_VERSION, 1, 99);
// Idempotency record must NOT exist (transaction rolled back)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-cas");
assertNull(idem, "idempotency insert must roll back when CAS fails");
@@ -411,9 +413,14 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
// Answer must NOT be changed
PracticeQuestionDO reloaded = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(99), reloaded.getClientSequence());
assertNull(reloaded.getSelectedAnswer());
assertFalse(reloaded.getIsAnswered());
PracticeSessionDO reloadedSession = sessionMapper.selectById(f.sessionId);
assertEquals(Integer.valueOf(1), reloadedSession.getVersion());
assertNull(reloadedSession.getLastClientSequence());
// Idempotency must NOT be committed (rollback)
AnswerIdempotencyDO idem = idempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-uan");
assertNull(idem, "idempotency must roll back when question CAS fails");
}
@@ -475,14 +482,8 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
}
@Test
void shouldRejectMalformedOptionsBlank() {
SessionFixture f = createSession("uuid-opt-blank");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setOptions(" ");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-ob", 1, 1);
assertServiceException(
() -> service.submitAnswer(req, userId, tenantId),
() -> QuestionContentSafety.restoreSnapshotOptions("choice", " "),
CATALOG_UPSTREAM_OPTIONS_MALFORMED);
}
@@ -538,6 +539,27 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
ANSWER_OPTION_INVALID, "Z");
}
@Test
void shouldRejectOptionlessAnswerUntilSubjectiveContractExists() {
SessionFixture f = createSession("uuid-optionless");
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
question.setType("short_answer");
question.setOptions("[]");
questionMapper.updateById(question);
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-optionless", 1, 1);
req.setSelectedAnswer("free text");
assertServiceException(() -> service.submitAnswer(req, userId, tenantId), ANSWER_TYPE_UNSUPPORTED);
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO reloaded = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(1), session.getVersion());
assertNull(session.getLastClientSequence());
assertNull(reloaded.getSelectedAnswer());
assertFalse(reloaded.getIsAnswered());
assertNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", "idem-optionless"));
}
// ========== Cross-user ==========
@Test
@@ -666,6 +688,49 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
"concurrent identical requests must return same response");
}
@Test
void shouldRejectConcurrentDifferentPayloadWithSameIdempotencyKey() throws Exception {
SessionFixture f = createSession("uuid-race-conflicting-payload");
PracticeAnswerReqVO first = createAnswerReq(f.sessionId, "idem-race-conflict", 1, 1);
first.setSelectedAnswer("A");
PracticeAnswerReqVO second = createAnswerReq(f.sessionId, "idem-race-conflict", 1, 1);
second.setSelectedAnswer("B");
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeAnswerRespVO> firstResponse = new AtomicReference<>();
AtomicReference<PracticeAnswerRespVO> secondResponse = new AtomicReference<>();
AtomicReference<Exception> firstError = new AtomicReference<>();
AtomicReference<Exception> secondError = new AtomicReference<>();
Thread firstThread = answerThread(first, ready, go, firstResponse, firstError);
Thread secondThread = answerThread(second, ready, go, secondResponse, secondError);
firstThread.start();
secondThread.start();
assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS));
go.countDown();
firstThread.join(10000);
secondThread.join(10000);
assertFalse(firstThread.isAlive());
assertFalse(secondThread.isAlive());
long successCount = java.util.stream.Stream.of(firstResponse.get(), secondResponse.get())
.filter(java.util.Objects::nonNull).count();
List<Exception> errors = java.util.stream.Stream.of(firstError.get(), secondError.get())
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, successCount);
assertEquals(1, errors.size());
assertInstanceOf(ServiceException.class, errors.get(0));
assertEquals(ANSWER_IDEMPOTENCY_CONFLICT.getCode(), ((ServiceException) errors.get(0)).getCode());
PracticeAnswerRespVO winner = firstResponse.get() != null ? firstResponse.get() : secondResponse.get();
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(2), session.getVersion());
assertEquals(winner.getSelectedAnswer(), question.getSelectedAnswer());
IdempotencyDO record = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-race-conflict");
assertNotNull(record);
assertFalse(record.getResponseJson().isBlank());
}
// ========== Concurrent different-key ==========
@Test
@@ -708,13 +773,22 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
t1.join(10000);
t2.join(10000);
// One should succeed, the other should get stale version (CAS failed)
int successCount = (e1.get() == null ? 1 : 0) + (e2.get() == null ? 1 : 0);
assertTrue(successCount >= 1, "at least one must succeed");
assertEquals(1, successCount, "exactly one concurrent different-key request must succeed");
Exception loser = e1.get() != null ? e1.get() : e2.get();
assertInstanceOf(ServiceException.class, loser);
assertEquals(ANSWER_STALE_VERSION.getCode(), ((ServiceException) loser).getCode());
// Version must be exactly 2 (only one CAS succeeded)
PracticeAnswerRespVO winner = r1.get() != null ? r1.get() : r2.get();
String winnerKey = r1.get() != null ? "idem-dk1" : "idem-dk2";
String loserKey = r1.get() != null ? "idem-dk2" : "idem-dk1";
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(2, session.getVersion());
assertEquals(winner.getAcceptedSequence(), session.getLastClientSequence());
assertEquals(winner.getAcceptedSequence(), question.getClientSequence());
assertNotNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", winnerKey));
assertNull(idempotencyMapper.selectByKey(tenantId, userId, "SUBMIT_ANSWER", loserKey));
}
// ========== No correctness leak ==========
@@ -754,6 +828,27 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
assertEquals("A", currentResp.getQuestions().get(0).getSelectedAnswer());
}
@Test
void shouldFailClosedWhenStoredReplayResponseIsIncomplete() {
SessionFixture f = createSession("uuid-incomplete-replay");
PracticeAnswerReqVO req = createAnswerReq(f.sessionId, "idem-incomplete", 1, 1);
service.submitAnswer(req, userId, tenantId);
IdempotencyDO record = idempotencyMapper.selectByKey(
tenantId, userId, "SUBMIT_ANSWER", "idem-incomplete");
record.setResponseJson("{}");
idempotencyMapper.updateById(record);
assertServiceException(() -> service.submitAnswer(req, userId, tenantId),
ANSWER_IDEMPOTENCY_REPLAY_INVALID);
PracticeSessionDO session = sessionMapper.selectById(f.sessionId);
PracticeQuestionDO question = questionMapper.selectById(f.questionId);
assertEquals(Integer.valueOf(2), session.getVersion());
assertEquals(Integer.valueOf(1), session.getLastClientSequence());
assertEquals("A", question.getSelectedAnswer());
assertEquals(Integer.valueOf(1), question.getClientSequence());
}
// ========== Idempotency: different payload conflict ==========
@Test
@@ -811,6 +906,20 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
// ========== private helpers ==========
private Thread answerThread(PracticeAnswerReqVO req, CountDownLatch ready, CountDownLatch go,
AtomicReference<PracticeAnswerRespVO> response,
AtomicReference<Exception> error) {
return new Thread(() -> {
try {
ready.countDown();
go.await();
response.set(service.submitAnswer(req, userId, tenantId));
} catch (Exception ex) {
error.set(ex);
}
});
}
private PracticeQuestionDO createQuestion(Long sessionId, int sequence, String questionId,
String options) {
PracticeQuestionDO q = new PracticeQuestionDO();

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
@@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -34,7 +35,7 @@ import static org.mockito.Mockito.when;
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
public class PracticeSessionServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -42,7 +43,7 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@MockitoBean
@@ -51,6 +52,9 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private WrongQuestionService wrongQuestionService;
@MockitoBean
private ScoringService scoringService;
// ========== Create: basic ==========
@Test
@@ -406,6 +410,122 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
assertEquals("v1", reloaded.getQuestions().get(0).getContentVersion());
}
@Test
void shouldRestoreOriginalSnapshotAfterSourceContentChanges() {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "Original stem"))));
PracticeSessionRespVO created = service.createPracticeSession(
createReq("uuid-snapshot-source-change", "col-001", 1), 100L, 1L);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(questionDTO("q-001", "v2", "Changed source stem"))));
PracticeSessionRespVO restored = service.getSession(created.getSessionId(), 100L, 1L);
assertEquals("Original stem", restored.getQuestions().get(0).getStem());
assertEquals("v1", restored.getQuestions().get(0).getContentVersion());
org.mockito.Mockito.verify(provider, org.mockito.Mockito.times(1))
.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1));
}
@Test
void shouldRejectDisabledProviderBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(false);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-disabled", "col-001", 1), 100L, 1L));
assertEquals(CATALOG_DATA_SOURCE_DISABLED.getCode(), ex.getCode());
}
@Test
void shouldRejectUnavailableProviderPageBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-unavailable", "col-001", 1), 100L, 1L));
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
assertTrue(sessionMapper.selectList().isEmpty());
assertTrue(questionMapper.selectList().isEmpty());
}
@Test
void shouldRejectInvisibleQuestionBeforeCreatingSession() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-hidden")
.contentVersion("v1")
.stem("hidden")
.type("fill")
.difficulty("easy")
.isPublished(false)
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-hidden", "col-001", 1), 100L, 1L));
assertEquals(QUESTION_NOT_VISIBLE.getCode(), ex.getCode());
}
@Test
void shouldRejectUnsafeOptionBackedQuestionBeforePersistingSnapshot() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-unsafe")
.contentVersion("v1")
.stem("unsafe")
.type("choice")
.difficulty("easy")
.isPublished(true)
.options(List.of(CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("A").content("Only one").isCorrect(true).order(1.0).build()))
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
ServiceException ex = assertThrows(ServiceException.class,
() -> service.createPracticeSession(createReq("uuid-unsafe", "col-001", 1), 100L, 1L));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
assertTrue(questionMapper.selectList().isEmpty());
}
@Test
void shouldFailClosedWhenRestoringMalformedSnapshot() {
when(provider.isEnabled()).thenReturn(true);
CatalogQuestionDTO q = CatalogQuestionDTO.builder()
.id("q-restore")
.contentVersion("v1")
.stem("restore")
.type("choice")
.difficulty("easy")
.isPublished(true)
.options(List.of(
CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("A").content("First").order(1.0).build(),
CatalogQuestionDTO.QuestionOptionDTO.builder()
.label("B").content("Second").order(2.0).build()))
.build();
when(provider.listQuestions(eq("col-001"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(List.of(q)));
PracticeSessionRespVO created = service.createPracticeSession(
createReq("uuid-restore", "col-001", 1), 100L, 1L);
PracticeQuestionDO stored = questionMapper.selectBySessionIdOrderBySequence(created.getSessionId()).get(0);
stored.setOptions("{}");
questionMapper.updateById(stored);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.getSession(created.getSessionId(), 100L, 1L));
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldNeverStoreCorrectAnswerInOptionsSnapshot() {
when(provider.isEnabled()).thenReturn(true);
@@ -462,7 +582,7 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
.id(id)
.contentVersion(version)
.stem(stem)
.type("choice")
.type("fill")
.difficulty("easy")
.isPublished(true)
.build();

View File

@@ -0,0 +1,152 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
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.question.dto.CatalogQuestionDTO;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.SESSION_IDEMPOTENCY_MISMATCH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
@Import(PracticeSessionServiceImpl.class)
class PracticeSessionServicePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@Resource
private PracticeSessionMapper sessionMapper;
@Autowired
private PracticeQuestionMapper questionMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@MockitoBean
private WrongQuestionService wrongQuestionService;
@MockitoBean
private ScoringService scoringService;
@Test
void shouldReturnOneSessionForConcurrentIdenticalCreates() throws Exception {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), isNull(), eq(1), eq(1)))
.thenReturn(pageResult(question("q-1", "stem")));
PracticeSessionCreateReqVO req = createReq("postgres-race", "col-race", null);
ConcurrentResult result = runConcurrently(req, req);
assertNull(result.firstError.get());
assertNull(result.secondError.get());
assertNotNull(result.firstResponse.get());
assertNotNull(result.secondResponse.get());
assertEquals(result.firstResponse.get().getSessionId(), result.secondResponse.get().getSessionId());
assertEquals(1, sessionMapper.selectList().size());
assertEquals(1, questionMapper.selectList().size());
}
@Test
void shouldRejectConcurrentCreateWithDifferentFingerprint() throws Exception {
when(provider.isEnabled()).thenReturn(true);
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), eq("easy"), eq(1), eq(1)))
.thenReturn(pageResult(question("q-easy", "easy")));
when(provider.listQuestions(eq("col-race"), isNull(), isNull(), eq("hard"), eq(1), eq(1)))
.thenReturn(pageResult(question("q-hard", "hard")));
PracticeSessionCreateReqVO easy = createReq("postgres-race-conflict", "col-race", "easy");
PracticeSessionCreateReqVO hard = createReq("postgres-race-conflict", "col-race", "hard");
ConcurrentResult result = runConcurrently(easy, hard);
long successes = java.util.stream.Stream.of(result.firstResponse.get(), result.secondResponse.get())
.filter(java.util.Objects::nonNull).count();
List<Exception> errors = java.util.stream.Stream.of(result.firstError.get(), result.secondError.get())
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, successes);
assertEquals(1, errors.size());
assertInstanceOf(ServiceException.class, errors.get(0));
assertEquals(SESSION_IDEMPOTENCY_MISMATCH.getCode(), ((ServiceException) errors.get(0)).getCode());
assertEquals(1, sessionMapper.selectList().size());
assertEquals(1, questionMapper.selectList().size());
}
private ConcurrentResult runConcurrently(PracticeSessionCreateReqVO first, PracticeSessionCreateReqVO second)
throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeSessionRespVO> firstResponse = new AtomicReference<>();
AtomicReference<PracticeSessionRespVO> secondResponse = new AtomicReference<>();
AtomicReference<Exception> firstError = new AtomicReference<>();
AtomicReference<Exception> secondError = new AtomicReference<>();
Thread firstThread = threadFor(first, ready, go, firstResponse, firstError);
Thread secondThread = threadFor(second, ready, go, secondResponse, secondError);
firstThread.start();
secondThread.start();
assertTrue(ready.await(5, TimeUnit.SECONDS));
go.countDown();
firstThread.join(10000);
secondThread.join(10000);
assertFalse(firstThread.isAlive());
assertFalse(secondThread.isAlive());
return new ConcurrentResult(firstResponse, secondResponse, firstError, secondError);
}
private Thread threadFor(PracticeSessionCreateReqVO req, CountDownLatch ready, CountDownLatch go,
AtomicReference<PracticeSessionRespVO> response, AtomicReference<Exception> error) {
return new Thread(() -> {
try {
ready.countDown();
go.await();
response.set(service.createPracticeSession(req, 100L, 1L));
} catch (Exception ex) {
error.set(ex);
}
});
}
private PracticeSessionCreateReqVO createReq(String clientSessionId, String collectionId, String difficulty) {
PracticeSessionCreateReqVO req = new PracticeSessionCreateReqVO();
req.setClientSessionId(clientSessionId);
req.setCollectionId(collectionId);
req.setDifficulty(difficulty);
req.setQuestionCount(1);
return req;
}
private CatalogQuestionPageResult pageResult(CatalogQuestionDTO question) {
return CatalogQuestionPageResult.builder().items(List.of(question)).total(1L).build();
}
private CatalogQuestionDTO question(String id, String stem) {
return CatalogQuestionDTO.builder()
.id(id).contentVersion("v1").stem(stem).type("fill").difficulty("easy").isPublished(true)
.build();
}
private record ConcurrentResult(
AtomicReference<PracticeSessionRespVO> firstResponse,
AtomicReference<PracticeSessionRespVO> secondResponse,
AtomicReference<Exception> firstError,
AtomicReference<Exception> secondError) {
}
}

View File

@@ -2,15 +2,16 @@ package cn.iocoder.yudao.module.education.service.practice;
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.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 cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -27,8 +28,8 @@ import static org.junit.jupiter.api.Assertions.*;
*
* @author 恭学教育
*/
@Import({PracticeSessionServiceImpl.class, WrongQuestionServiceImpl.class})
public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class, WrongQuestionServiceImpl.class})
public class PracticeSubmitProjectionIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -36,7 +37,7 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource

View File

@@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.education.service.practice;
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.test.PostgreSqlDbIntegrationTest;
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.*;
@@ -10,9 +10,11 @@ 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;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
@@ -23,12 +25,12 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* PracticeSubmitService test — real DB (H2) backing all submit + report assertions.
* PracticeSubmitService test — real PostgreSQL backing all submit + report assertions.
*
* @author 恭学教育
*/
@Import(PracticeSessionServiceImpl.class)
public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
@Import({PracticeSessionServiceImpl.class, ScoringServiceImpl.class})
public class PracticeSubmitServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private PracticeSessionService service;
@@ -36,7 +38,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
@Resource
@@ -46,7 +48,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
private PracticeReportDetailMapper reportDetailMapper;
@Resource
private SubmitIdempotencyMapper submitIdempotencyMapper;
private IdempotencyStoreMapper idempotencyStoreMapper;
@MockitoBean
private QuestionCatalogProvider provider;
@@ -225,7 +227,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
assertEquals(1, reports.size());
// Exactly one idempotency record
List<SubmitIdempotencyDO> idempotencies = submitIdempotencyMapper.selectList();
List<IdempotencyDO> idempotencies = idempotencyStoreMapper.selectList();
assertEquals(1, idempotencies.stream()
.filter(r -> "idem-replay".equals(r.getIdempotencyKey())).count());
}
@@ -840,7 +842,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
SUBMIT_STALE_VERSION, 1, 99);
// Verify: no idempotency, no report, no details were committed
SubmitIdempotencyDO idem = submitIdempotencyMapper.selectByKey(
IdempotencyDO idem = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-cas-rb");
assertNull(idem, "idempotency must not exist after CAS failure rollback");
@@ -920,7 +922,7 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// Create a second session for the "loser" — same session but new key
// Simulating what happens when different thread submits same session:
// INSERT IGNORE report returns 0, idempotency deleted, winner report returned
// ON CONFLICT DO NOTHING returns 0, idempotency deleted, winner report returned
// Use a different key to submit → should return winner's report
PracticeSubmitReqVO loserReq = createSubmitReq(f.sessionId, "idem-loser", 1);
@@ -928,10 +930,12 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
assertNotNull(loserResp.getReportId(), "loser must return winner's report with non-null reportId");
assertEquals(winnerResp.getReportId(), loserResp.getReportId());
// Loser's idempotency should NOT exist (deleted on insert-ignore-failure)
SubmitIdempotencyDO loserIdem = submitIdempotencyMapper.selectByKey(
// The losing key is completed against the immutable winner report so retries are stable.
IdempotencyDO loserIdem = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-loser");
assertNull(loserIdem, "loser idempotency must have been deleted");
assertNotNull(loserIdem);
assertEquals("COMPLETED", loserIdem.getStatus());
assertEquals(winnerResp.getReportId(), loserIdem.getReportId());
// Retry with loser's key → must return winner's report again (never null)
PracticeSubmitRespVO loserRetryResp = service.submitSession(loserReq, userId, tenantId);
@@ -981,21 +985,118 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// Exactly one report, one idempotency record
List<PracticeReportDO> reports = reportMapper.selectList();
assertEquals(1, reports.size());
List<SubmitIdempotencyDO> idems = submitIdempotencyMapper.selectList();
List<IdempotencyDO> idems = idempotencyStoreMapper.selectList();
assertEquals(1, idems.stream().filter(i -> "idem-same".equals(i.getIdempotencyKey())).count());
}
@Test
void shouldTakeOverExpiredSubmitClaim() {
SessionFixture f = createSessionWithQuestions("uuid-expired-submit-claim", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-expired-submit-claim", 1);
IdempotencyDO staleClaim = new IdempotencyDO();
staleClaim.setTenantId(tenantId);
staleClaim.setUserId(userId);
staleClaim.setOperation("SUBMIT_SESSION");
staleClaim.setIdempotencyKey(req.getIdempotencyKey());
staleClaim.setRequestHash(computeSubmitHashForTest(req));
staleClaim.setSessionId(f.sessionId);
staleClaim.setStatus("PROCESSING");
staleClaim.setClaimToken("crashed-process");
staleClaim.setClaimStartedAt(LocalDateTime.now().minusMinutes(5));
idempotencyStoreMapper.insertIgnore(staleClaim);
PracticeSubmitRespVO response = service.submitSession(req, userId, tenantId);
assertNotNull(response.getReportId());
IdempotencyDO completed = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", req.getIdempotencyKey());
assertEquals("COMPLETED", completed.getStatus());
assertNull(completed.getClaimToken());
assertNull(completed.getClaimStartedAt());
}
@Test
void shouldFailClosedForActiveSubmitClaim() {
SessionFixture f = createSessionWithQuestions("uuid-active-submit-claim", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-active-submit-claim", 1);
IdempotencyDO activeClaim = new IdempotencyDO();
activeClaim.setTenantId(tenantId);
activeClaim.setUserId(userId);
activeClaim.setOperation("SUBMIT_SESSION");
activeClaim.setIdempotencyKey(req.getIdempotencyKey());
activeClaim.setRequestHash(computeSubmitHashForTest(req));
activeClaim.setSessionId(f.sessionId);
activeClaim.setStatus("PROCESSING");
activeClaim.setClaimToken("active-process");
activeClaim.setClaimStartedAt(LocalDateTime.now());
idempotencyStoreMapper.insertIgnore(activeClaim);
assertServiceException(() -> service.submitSession(req, userId, tenantId), SUBMIT_CONCURRENT_CONFLICT);
assertNull(reportMapper.selectBySessionIdAndTenant(f.sessionId, tenantId));
}
@Test
void shouldFailClosedWhenStoredSubmitReplayIsIncomplete() {
SessionFixture f = createSessionWithQuestions("uuid-incomplete-submit-replay", 2);
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-incomplete-submit", 1);
service.submitSession(req, userId, tenantId);
IdempotencyDO record = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-incomplete-submit");
record.setResponseJson("{}");
idempotencyStoreMapper.updateById(record);
assertServiceException(() -> service.submitSession(req, userId, tenantId),
SUBMIT_IDEMPOTENCY_REPLAY_INVALID);
assertEquals(1, reportMapper.selectList().size());
assertEquals("SUBMITTED", sessionMapper.selectById(f.sessionId).getStatus());
}
@Test
void shouldRejectConcurrentSameKeyDifferentPayload() throws Exception {
SessionFixture f = createSessionWithQuestions("uuid-race-diff-payload", 2);
answerQuestion(f.questions.get(0).getId(), "B", true);
SessionFixture firstSession = createSessionWithQuestions("uuid-race-diff-payload-a", 2);
SessionFixture changedSession = createSessionWithQuestions("uuid-race-diff-payload-b", 2);
answerQuestion(firstSession.questions.get(0).getId(), "B", true);
answerQuestion(changedSession.questions.get(0).getId(), "B", true);
PracticeSubmitReqVO first = createSubmitReq(f.sessionId, "idem-diff-payload", 1);
PracticeSubmitReqVO changed = createSubmitReq(f.sessionId, "idem-diff-payload", 2);
PracticeSubmitReqVO first = createSubmitReq(firstSession.sessionId, "idem-diff-payload", 1);
PracticeSubmitReqVO changed = createSubmitReq(changedSession.sessionId, "idem-diff-payload", 1);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeSubmitRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeSubmitRespVO> r2 = new AtomicReference<>();
AtomicReference<Exception> e1 = new AtomicReference<>();
AtomicReference<Exception> e2 = new AtomicReference<>();
PracticeSubmitRespVO winner = service.submitSession(first, userId, tenantId);
assertNotNull(winner.getReportId());
assertServiceException(() -> service.submitSession(changed, userId, tenantId), SUBMIT_IDEMPOTENCY_CONFLICT);
Thread t1 = new Thread(() -> {
try { ready.countDown(); go.await(); r1.set(service.submitSession(first, userId, tenantId)); }
catch (Exception e) { e1.set(e); }
});
Thread t2 = new Thread(() -> {
try { ready.countDown(); go.await(); r2.set(service.submitSession(changed, userId, tenantId)); }
catch (Exception e) { e2.set(e); }
});
t1.start(); t2.start(); ready.await(); go.countDown();
t1.join(10000); t2.join(10000);
int successCount = (r1.get() != null ? 1 : 0) + (r2.get() != null ? 1 : 0);
assertEquals(1, successCount);
int conflictCount = 0;
for (Exception error : new Exception[]{e1.get(), e2.get()}) {
if (error instanceof ServiceException serviceError
&& serviceError.getCode() == SUBMIT_IDEMPOTENCY_CONFLICT.getCode()) {
conflictCount++;
}
}
assertEquals(1, conflictCount);
assertEquals(1, reportMapper.selectList().size());
IdempotencyDO idempotency = idempotencyStoreMapper.selectByKey(
tenantId, userId, "SUBMIT_SESSION", "idem-diff-payload");
assertNotNull(idempotency);
assertEquals("COMPLETED", idempotency.getStatus());
assertNotNull(idempotency.getReportId());
}
// ========== Ticket #8 #4: Cross-tenant/user report detail isolation ==========
@@ -1044,6 +1145,14 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
// ========== helper for test — mirror canonical storage ==========
private String computeSubmitHashForTest(PracticeSubmitReqVO req) {
java.util.Map<String, Object> canonical = new java.util.TreeMap<>();
canonical.put("sessionId", req.getSessionId());
canonical.put("expectedSessionVersion", req.getExpectedSessionVersion());
return cn.hutool.crypto.digest.DigestUtil.sha256Hex(
cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(canonical));
}
/**
* Mirror correctAnswerToJson for test seeding.
*/

View File

@@ -41,6 +41,30 @@ class QuestionCatalogServiceImplTest {
// ========== Safety field stripping ==========
@Test
void shouldFailClosedWhenVisibleOptionBackedQuestionHasUnsafeOptions() {
CatalogQuestionDTO dto = questionDto("q-unsafe", "Unsafe", "choice", "easy", true,
List.of(optionDto("A", "Only one", true)), null, null, null, null);
when(provider.getQuestion("q-unsafe")).thenReturn(dto);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.getQuestion("q-unsafe"));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldAllowVisibleOptionlessQuestionWithoutOptions() {
CatalogQuestionDTO dto = questionDto("q-text", "Explain", "short_answer", "easy", true,
null, null, null, null, null);
when(provider.getQuestion("q-text")).thenReturn(dto);
SafeQuestionRespVO result = service.getQuestion("q-text");
assertNotNull(result.getOptions());
assertTrue(result.getOptions().isEmpty());
}
@Test
void shouldStripCorrectAnswerAndExplanationFields() {
CatalogQuestionDTO dto = questionDto("q1", "What is 2+2?", "choice", "easy", true,
@@ -75,7 +99,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldStripAnswerFieldsInPageResponse() {
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true,
List.of(optionDto("A", "Yes", true)),
List.of(optionDto("A", "Yes", true), optionDto("B", "No", false)),
"A", "A", "Explain", "Analyze");
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
@@ -233,7 +257,7 @@ class QuestionCatalogServiceImplTest {
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 q1 = questionDto("q1", "Q1", "fill", "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)))
@@ -258,7 +282,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldUseProviderTotalForCollectionQuestions() {
CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true, null, null, null, null, null);
CatalogQuestionDTO dto = questionDto("q1", "Q1", "fill", "easy", true, null, null, null, null, null);
when(provider.listCollectionQuestions("col1", null, null, 1, 20))
.thenReturn(CatalogQuestionPageResult.builder()
@@ -276,12 +300,12 @@ class QuestionCatalogServiceImplTest {
@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 q1 = questionDto("q1", "Q1", "fill", "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")
.id("q3").stem("Q3").type("short_answer").difficulty("hard")
.isPublished(true).status(null).build();
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
@@ -498,7 +522,7 @@ class QuestionCatalogServiceImplTest {
@Test
void shouldNotLeakVisibilityChecksAcrossCalls() {
CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "choice", "easy", true, null,
CatalogQuestionDTO visible = questionDto("q-visible", "Visible", "fill", "easy", true, null,
null, null, null, null);
when(provider.getQuestion("q-visible")).thenReturn(visible);
@@ -517,7 +541,7 @@ class QuestionCatalogServiceImplTest {
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 q1 = questionDto("q1", "Q1", "fill", "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)))

View File

@@ -0,0 +1,123 @@
package cn.iocoder.yudao.module.education.service.question;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety.SafeOption;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.CATALOG_UPSTREAM_OPTIONS_MALFORMED;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.UNSAFE_PROVIDER_PAYLOAD;
import static org.junit.jupiter.api.Assertions.*;
class QuestionContentSafetyTest {
@Test
void shouldAcceptAndOrderValidOptionBackedQuestion() {
List<SafeOption> options = QuestionContentSafety.validateProviderOptions(" choice ", List.of(
option("B", "Second", false, 2.0),
option("A", "First", true, 1.0)));
assertEquals(List.of("A", "B"), options.stream().map(SafeOption::label).toList());
assertEquals(List.of("First", "Second"), options.stream().map(SafeOption::content).toList());
}
@Test
void shouldRejectOptionBackedQuestionWithoutTwoOptions() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("choice", List.of(
option("A", "Only", true, 1.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldRejectDuplicateTrimmedLabels() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("multi", List.of(
option("A", "First", true, 1.0),
option(" A ", "Duplicate", false, 2.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldAcceptOptionlessQuestionWithoutOptions() {
assertEquals(Collections.emptyList(),
QuestionContentSafety.validateProviderOptions("short_answer", null));
}
@Test
void shouldRejectOptionsOnOptionlessQuestion() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("short_answer", List.of(
option("A", "Unexpected", null, 1.0),
option("B", "Unexpected", null, 2.0))));
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), ex.getCode());
}
@Test
void shouldRejectUnsupportedCompositeAndUnknownTypes() {
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("reading", null)).getCode());
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions("mystery", null)).getCode());
assertEquals(UNSAFE_PROVIDER_PAYLOAD.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.validateProviderOptions(" ", null)).getCode());
}
@Test
void shouldCreateAnswerFreeSnapshotAndRestoreIt() {
String snapshot = QuestionContentSafety.toSafeSnapshotJson("choice", List.of(
option("A", "First", true, 1.0),
option("B", "Second", false, 2.0)));
assertFalse(snapshot.contains("isCorrect"));
assertFalse(snapshot.contains("correctAnswer"));
List<SafeOption> restored = QuestionContentSafety.restoreSnapshotOptions("choice", snapshot);
assertEquals(List.of("A", "B"), restored.stream().map(SafeOption::label).toList());
}
@Test
void shouldRestoreValidOptionlessSnapshot() {
assertEquals(Collections.emptyList(),
QuestionContentSafety.restoreSnapshotOptions("fill", null));
assertEquals(Collections.emptyList(),
QuestionContentSafety.restoreSnapshotOptions("fill", "[]"));
}
@Test
void shouldRejectAnswerBearingFieldsInSnapshot() {
ServiceException ex = assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice",
"[{\"label\":\"A\",\"content\":\"First\",\"order\":1,\"isCorrect\":true},"
+ "{\"label\":\"B\",\"content\":\"Second\",\"order\":2}]"));
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), ex.getCode());
}
@Test
void shouldFailClosedForMalformedOrTypeInconsistentSnapshot() {
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice", "not-json")).getCode());
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("choice", "[]")).getCode());
assertEquals(CATALOG_UPSTREAM_OPTIONS_MALFORMED.getCode(), assertThrows(ServiceException.class,
() -> QuestionContentSafety.restoreSnapshotOptions("short_answer",
"[{\"label\":\"A\",\"content\":\"Unexpected\",\"order\":1}]")).getCode());
}
private static CatalogQuestionDTO.QuestionOptionDTO option(String label, String content,
Boolean correct, Double order) {
return CatalogQuestionDTO.QuestionOptionDTO.builder()
.label(label)
.content(content)
.isCorrect(correct)
.order(order)
.build();
}
}

View File

@@ -2,7 +2,7 @@ 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.test.PostgreSqlDbIntegrationTest;
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;
@@ -11,6 +11,7 @@ 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.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
@@ -24,12 +25,12 @@ 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.
* WrongQuestionService test — real PostgreSQL backing all wrong question assertions.
*
* @author 恭学教育
*/
@Import(WrongQuestionServiceImpl.class)
public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
public class WrongQuestionServiceImplTest extends PostgreSqlDbIntegrationTest {
@Resource
private WrongQuestionService wrongQuestionService;
@@ -43,7 +44,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
@Autowired
private PracticeQuestionMapper questionMapper;
private final Long userId = 100L;
@@ -250,7 +251,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
@@ -281,7 +282,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
@@ -314,7 +315,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
@@ -342,7 +343,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
@@ -368,7 +369,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Question page " + i);
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
wq.setLastWrongTime(LocalDateTime.now().minusHours(i));
@@ -464,7 +465,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
@@ -493,7 +494,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
@@ -525,7 +526,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Detail question");
wq.setType("choice");
wq.setDifficulty("hard");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v2");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Because B is correct");
@@ -560,7 +561,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Old explanation");
@@ -603,7 +604,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
@@ -639,7 +640,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
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.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq1.setContentVersion("v1"); wq1.setFirstWrongTime(LocalDateTime.now());
wq1.setLastWrongTime(LocalDateTime.now()); wq1.setWrongCount(1);
wq1.setMasterStatus("PENDING");
@@ -648,7 +649,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
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.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq2.setContentVersion("v1"); wq2.setFirstWrongTime(LocalDateTime.now());
wq2.setLastWrongTime(LocalDateTime.now()); wq2.setWrongCount(1);
wq2.setMasterStatus("PENDING");
@@ -684,7 +685,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
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.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
@@ -712,7 +713,7 @@ public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
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.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"B\",\"order\":2.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");

View File

@@ -0,0 +1,337 @@
package cn.iocoder.yudao.module.education.test;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EducationFlywayMigrationIntegrationTest {
private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST");
private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT");
private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB");
private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER");
private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD");
private final List<String> schemas = new ArrayList<>();
@AfterEach
void dropSchemas() throws SQLException {
try (Connection connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
for (String schema : schemas) {
statement.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE");
}
}
}
@Test
void shouldFailClosedWhenLegacyTableContainsConflictingDuplicateKeys() throws SQLException {
String schema = createSchema("duplicate");
createCompatibleManualPracticeFixture(schema);
execute(schema, """
INSERT INTO education_answer_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, question_id,
selected_answer, response_json)
VALUES (1, 10, 'answer-key', 'different-hash', 100, 'q-1', 'B', '{"accepted":true}');
""");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("conflicting duplicate request_hash");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4030' AND success = TRUE"))
.isZero();
}
@Test
void shouldFailClosedWhenLegacyIdempotencyConflictsWithUnifiedHistory() throws SQLException {
String schema = createSchema("conflict");
createCompatibleManualPracticeFixture(schema);
execute(schema, """
CREATE TABLE education_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL, request_hash VARCHAR(64) NOT NULL,
session_id BIGINT NOT NULL, question_id VARCHAR(64), selected_answer TEXT, report_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT, business_payload JSONB,
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 BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_idempotency
(tenant_id, user_id, operation, idempotency_key, request_hash, session_id)
VALUES (1, 10, 'SUBMIT_ANSWER', 'answer-key', 'different-hash', 100);
""");
assertThatThrownBy(() -> configureFlyway(schema, true).load().migrate())
.hasMessageContaining("education_answer_idempotency conflicts")
.hasMessageContaining("request_hash");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4030' AND success = TRUE"))
.isZero();
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_answer_idempotency"))
.isEqualTo(1L);
}
@Test
void shouldMigratePlatformSchemaFromBaseline4009() throws SQLException {
String schema = createSchema("baseline");
execute(schema, "CREATE TABLE platform_marker (id BIGINT PRIMARY KEY)");
Flyway flyway = configureFlyway(schema, true).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
.containsExactly("4009", "4010", "4020", "4030", "4040");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
.isEqualTo(1L);
}
@Test
void shouldAdoptCompatibleManualPracticeSchemaAndBackfillIdempotency() throws SQLException {
String schema = createSchema("adoption");
createCompatibleManualPracticeFixture(schema);
Flyway flyway = configureFlyway(schema, true).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_practice_session' " +
"AND column_name IN ('last_client_sequence', 'review_fingerprint') ORDER BY column_name"))
.containsExactly("last_client_sequence", "review_fingerprint");
assertThat(queryStrings(schema,
"SELECT data_type FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_practice_question' " +
"AND column_name = 'options'"))
.containsExactly("jsonb");
assertThat(queryStrings(schema,
"SELECT operation || ':' || idempotency_key || ':' || request_hash " +
"FROM education_idempotency ORDER BY operation"))
.containsExactly(
"SUBMIT_ANSWER:answer-key:answer-hash",
"SUBMIT_SESSION:partial-submit-key:partial-submit-hash",
"SUBMIT_SESSION:submit-key:submit-hash");
assertThat(queryStrings(schema,
"SELECT idempotency_key || ':' || status FROM education_idempotency " +
"WHERE operation = 'SUBMIT_SESSION' ORDER BY idempotency_key"))
.containsExactly(
"partial-submit-key:PROCESSING",
"submit-key:COMPLETED");
assertThat(queryStrings(schema,
"SELECT column_name || ':' || data_type FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND table_name = 'education_idempotency' " +
"AND column_name IN ('claim_started_at', 'claim_token') ORDER BY column_name"))
.containsExactly(
"claim_started_at:timestamp without time zone",
"claim_token:character varying");
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_answer_idempotency"))
.isEqualTo(1L);
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_submit_idempotency"))
.isEqualTo(2L);
}
@Test
void shouldMigrateFreshPracticeSchemaThroughFlyway() throws SQLException {
String schema = createSchema("fresh");
Flyway flyway = configureFlyway(schema, false).load();
flyway.migrate();
flyway.validate();
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
.containsExactly("4010", "4020", "4030", "4040");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
"'education_practice_session', 'education_practice_question', " +
"'education_practice_report', 'education_practice_report_detail', " +
"'education_wrong_question', 'education_wrong_question_idempotency', " +
"'education_favorite', 'education_idempotency') ORDER BY table_name"))
.containsExactly(
"education_favorite",
"education_idempotency",
"education_practice_question",
"education_practice_report",
"education_practice_report_detail",
"education_practice_session",
"education_wrong_question",
"education_wrong_question_idempotency");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name IN ('education_answer_idempotency', 'education_submit_idempotency')"))
.isZero();
assertThat(queryStrings(schema,
"SELECT column_name || ':' || udt_name FROM information_schema.columns " +
"WHERE table_schema = current_schema() AND " +
"((table_name = 'education_practice_question' AND column_name = 'options') OR " +
"(table_name = 'education_practice_report_detail' AND column_name = 'options') OR " +
"(table_name = 'education_wrong_question' AND column_name = 'options') OR " +
"(table_name = 'education_favorite' AND column_name = 'options') OR " +
"(table_name = 'education_idempotency' AND column_name = 'business_payload')) " +
"ORDER BY table_name"))
.containsExactly(
"options:jsonb",
"business_payload:jsonb",
"options:jsonb",
"options:jsonb",
"options:jsonb");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (" +
"'uk_education_practice_session_tenant_client', " +
"'uk_education_practice_question_session_sequence', " +
"'uk_education_practice_report_tenant_session', " +
"'uk_education_practice_report_detail_report_sequence', " +
"'uk_education_wrong_question_tenant_user_question', " +
"'uk_education_wrong_idempotency_tenant_user_question_report', " +
"'uk_education_favorite_tenant_user_target', " +
"'uk_education_idempotency_tenant_user_operation_key')"))
.isEqualTo(8L);
}
private void createCompatibleManualPracticeFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE education_practice_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
client_session_id VARCHAR(36) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
question_count INTEGER NOT NULL DEFAULT 0,
collection_id VARCHAR(64), node_id VARCHAR(64), type VARCHAR(32), difficulty VARCHAR(32),
version INTEGER NOT NULL DEFAULT 0,
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 BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE education_practice_question (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, session_id BIGINT NOT NULL, sequence INTEGER NOT NULL,
question_id VARCHAR(64) NOT NULL, content_version VARCHAR(64) NOT NULL DEFAULT '',
stem TEXT NOT NULL, type VARCHAR(32) NOT NULL, difficulty VARCHAR(32), options TEXT NOT NULL,
selected_answer TEXT, is_answered BOOLEAN NOT NULL DEFAULT false,
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 BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_practice_question
(tenant_id, session_id, sequence, question_id, stem, type, options)
VALUES (1, 100, 1, 'q-1', 'question', 'choice', '[{"label":"A","content":"1"}]');
CREATE TABLE education_answer_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
operation VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER', idempotency_key VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL, session_id BIGINT NOT NULL, question_id VARCHAR(64) NOT NULL,
selected_answer TEXT, status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT 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 BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE education_submit_idempotency (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
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,
status VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED', response_json TEXT 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 BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO education_answer_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, question_id,
selected_answer, response_json)
VALUES (1, 10, 'answer-key', 'answer-hash', 100, 'q-1', 'A', '{"accepted":true}');
INSERT INTO education_submit_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, report_id, response_json)
VALUES (1, 10, 'submit-key', 'submit-hash', 100, 200, '{"reportId":200}');
INSERT INTO education_submit_idempotency
(tenant_id, user_id, idempotency_key, request_hash, session_id, report_id, response_json)
VALUES (1, 10, 'partial-submit-key', 'partial-submit-hash', 101, NULL, '');
""");
}
private void execute(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute(sql);
}
}
private String createSchema(String suffix) throws SQLException {
String schema = "edu_flyway_" + suffix + "_" + UUID.randomUUID().toString().replace("-", "");
try (Connection connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + schema);
}
schemas.add(schema);
return schema;
}
private org.flywaydb.core.api.configuration.FluentConfiguration configureFlyway(
String schema, boolean baselineOnMigrate) {
return Flyway.configure()
.dataSource(jdbcUrl(schema), USER, PASSWORD)
.locations("classpath:db/migration/education")
.schemas(schema)
.defaultSchema(schema)
.baselineOnMigrate(baselineOnMigrate)
.baselineVersion("4009")
.validateOnMigrate(true)
.cleanDisabled(true)
.outOfOrder(false);
}
private List<String> queryStrings(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
List<String> values = new ArrayList<>();
while (resultSet.next()) {
values.add(resultSet.getString(1));
}
return values;
}
}
private long queryLong(String schema, String sql) throws SQLException {
try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), USER, PASSWORD);
var statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(sql)) {
resultSet.next();
return resultSet.getLong(1);
}
}
private static String jdbcUrl(String schema) {
return adminUrl() + "?currentSchema=" + schema + "&stringtype=unspecified";
}
private static String adminUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE;
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " must be set for PostgreSQL integration tests");
}
return value;
}
}

View File

@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.education.test;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.datasource.config.YudaoDataSourceAutoConfiguration;
import cn.iocoder.yudao.framework.mybatis.config.YudaoMybatisAutoConfiguration;
import org.flywaydb.core.Flyway;
import com.alibaba.druid.spring.boot4.autoconfigure.DruidDataSourceAutoConfigure;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.UUID;
/**
* PostgreSQL persistence seam for Education tests.
*
* <p>All subclasses in one Maven JVM share a random disposable schema and are
* serialized with a JUnit resource lock. Their Spring contexts are closed after
* each class so schema initialization cannot be reused after the schema is dropped.
* Module-owned Flyway migrations create the disposable schema before Spring starts.</p>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,
classes = PostgreSqlDbIntegrationTest.Application.class)
@ActiveProfiles("education-postgresql-test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@ResourceLock(value = "education-postgresql-schema", mode = ResourceAccessMode.READ_WRITE)
@Sql(scripts = "/sql/postgresql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public abstract class PostgreSqlDbIntegrationTest {
private static final String HOST = requiredEnv("EDU_TEST_POSTGRES_HOST");
private static final String PORT = requiredEnv("EDU_TEST_POSTGRES_PORT");
private static final String DATABASE = requiredEnv("EDU_TEST_POSTGRES_DB");
private static final String USER = requiredEnv("EDU_TEST_POSTGRES_USER");
private static final String PASSWORD = requiredEnv("EDU_TEST_POSTGRES_PASSWORD");
private static final String SCHEMA = "edu_test_" + UUID.randomUUID().toString().replace("-", "");
@BeforeAll
static void createSchema() throws SQLException {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("CREATE SCHEMA " + SCHEMA);
}
Flyway.configure()
.dataSource(jdbcUrl(), USER, PASSWORD)
.locations("classpath:db/migration/education")
.schemas(SCHEMA)
.defaultSchema(SCHEMA)
.baselineOnMigrate(false)
.validateOnMigrate(true)
.cleanDisabled(true)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void dropSchema() throws SQLException {
try (var connection = DriverManager.getConnection(adminUrl(), USER, PASSWORD);
var statement = connection.createStatement()) {
statement.execute("DROP SCHEMA IF EXISTS " + SCHEMA + " CASCADE");
}
}
@DynamicPropertySource
static void postgresqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", PostgreSqlDbIntegrationTest::jdbcUrl);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.datasource.username", () -> USER);
registry.add("spring.datasource.password", () -> PASSWORD);
registry.add("spring.sql.init.mode", () -> "never");
}
private static String jdbcUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE
+ "?currentSchema=" + SCHEMA + "&stringtype=unspecified";
}
private static String adminUrl() {
return "jdbc:postgresql://" + HOST + ":" + PORT + "/" + DATABASE;
}
private static String requiredEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException(name + " must be set for PostgreSQL integration tests");
}
return value;
}
@Import({
YudaoDataSourceAutoConfiguration.class,
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
DruidDataSourceAutoConfigure.class,
YudaoMybatisAutoConfiguration.class,
MybatisPlusAutoConfiguration.class,
MybatisPlusJoinAutoConfiguration.class,
SpringUtil.class
})
public static class Application {
}
}

View File

@@ -0,0 +1,28 @@
spring:
main:
lazy-initialization: true
banner-mode: off
sql:
init:
mode: always
continue-on-error: false
data:
redis:
host: 127.0.0.1
port: 16379
database: 0
yudao:
info:
base-package: cn.iocoder.yudao.module
education:
enabled: true
mybatis-plus:
global-config:
db-config:
logic-delete-value: true
logic-not-delete-value: false
mybatis:
lazy-initialization: true

View File

@@ -4,6 +4,7 @@ DELETE FROM education_wrong_question;
DELETE FROM education_practice_report_detail;
DELETE FROM education_practice_report;
DELETE FROM education_idempotency;
DELETE FROM education_submit_idempotency;
DELETE FROM education_practice_question;
DELETE FROM education_practice_session;

View File

@@ -95,6 +95,31 @@ CREATE TABLE IF NOT EXISTS "education_submit_idempotency" (
CREATE INDEX IF NOT EXISTS "idx_submit_session" ON "education_submit_idempotency" ("tenant_id", "session_id");
CREATE TABLE IF NOT EXISTS "education_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,
"idempotency_key" VARCHAR(64) NOT NULL,
"request_hash" VARCHAR(64) NOT NULL,
"session_id" BIGINT NOT NULL,
"question_id" VARCHAR(64) DEFAULT NULL,
"selected_answer" CLOB DEFAULT NULL,
"report_id" BIGINT DEFAULT NULL,
"status" VARCHAR(20) NOT NULL,
"response_json" CLOB DEFAULT NULL,
"business_payload" 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" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_education_idempotency" UNIQUE ("tenant_id", "user_id", "operation", "idempotency_key")
);
CREATE INDEX IF NOT EXISTS "idx_education_idempotency_session" ON "education_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,

View File

@@ -0,0 +1,12 @@
-- PostgreSQL persistence test cleanup.
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
TRUNCATE TABLE
education_idempotency,
education_favorite,
education_wrong_question_idempotency,
education_wrong_question,
education_practice_report_detail,
education_practice_report,
education_practice_question,
education_practice_session
RESTART IDENTITY CASCADE;