forked from wangziqi/ruoyi-vue-pro
feat(education): complete Flyway migration and atomic submit
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user