forked from wangziqi/ruoyi-vue-pro
feat(education): complete student core loop delivery
This commit is contained in:
@@ -47,6 +47,9 @@ public class EducationPropertiesTest {
|
||||
EducationProperties defaults = new EducationProperties();
|
||||
assertFalse(defaults.isEnabled(), "默认应禁用");
|
||||
assertEquals("1.0.0", defaults.getVersion(), "默认版本应为 1.0.0");
|
||||
assertTrue(defaults.isCatalogReadEnabled(), "模块启用后题库读取默认开放");
|
||||
assertTrue(defaults.isPracticeWriteEnabled(), "模块启用后练习写入默认开放");
|
||||
assertTrue(defaults.getPilotTenantIds().isEmpty(), "Pilot 租户为空时不限制租户");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.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;
|
||||
@@ -49,6 +50,9 @@ class CatalogControllerHttpTest {
|
||||
var field = CatalogController.class.getDeclaredField("catalogService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, catalogService);
|
||||
var accessField = CatalogController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.education.controller.app.catalog;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
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;
|
||||
@@ -31,7 +33,9 @@ import static org.mockito.Mockito.when;
|
||||
@SpringBootTest(
|
||||
classes = {
|
||||
CatalogController.class,
|
||||
CatalogServiceImpl.class
|
||||
CatalogServiceImpl.class,
|
||||
EducationAccessService.class,
|
||||
EducationProperties.class
|
||||
},
|
||||
properties = {
|
||||
"yudao.education.enabled=true",
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -50,6 +51,9 @@ class PracticeAnswerControllerHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
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;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -55,6 +56,9 @@ class PracticeSessionControllerHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -53,6 +54,9 @@ class PracticeSessionControllerSubmitHttpTest {
|
||||
var field = PracticeSessionController.class.getDeclaredField("practiceSessionService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = PracticeSessionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl;
|
||||
@@ -55,6 +56,9 @@ class QuestionControllerHttpTest {
|
||||
var field = QuestionController.class.getDeclaredField("questionCatalogService");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
var accessField = QuestionController.class.getDeclaredField("educationAccessService");
|
||||
accessField.setAccessible(true);
|
||||
accessField.set(controller, mock(EducationAccessService.class));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package cn.iocoder.yudao.module.education.service.access;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_CATALOG_READ_DISABLED;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_PRACTICE_WRITE_DISABLED;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_NOT_IN_PILOT;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class EducationAccessServiceTest {
|
||||
|
||||
private EducationProperties properties;
|
||||
private EducationAccessService accessService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
properties = new EducationProperties();
|
||||
accessService = new EducationAccessService();
|
||||
Field field = EducationAccessService.class.getDeclaredField("properties");
|
||||
field.setAccessible(true);
|
||||
field.set(accessService, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowAllTenantsWhenPilotListEmpty() {
|
||||
assertDoesNotThrow(() -> accessService.assertCatalogReadAllowed(100L));
|
||||
assertDoesNotThrow(() -> accessService.assertPracticeWriteAllowed(100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTenantOutsidePilot() {
|
||||
properties.setPilotTenantIds(List.of(100L));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertCatalogReadAllowed(200L));
|
||||
|
||||
assertEquals(EDUCATION_TENANT_NOT_IN_PILOT.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisableCatalogWithoutDisablingHistoryReads() {
|
||||
properties.setCatalogReadEnabled(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertCatalogReadAllowed(100L));
|
||||
|
||||
assertEquals(EDUCATION_CATALOG_READ_DISABLED.getCode(), ex.getCode());
|
||||
assertDoesNotThrow(() -> accessService.assertPilotTenant(100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisablePracticeWritesIndependently() {
|
||||
properties.setPracticeWriteEnabled(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> accessService.assertPracticeWriteAllowed(100L));
|
||||
|
||||
assertEquals(EDUCATION_PRACTICE_WRITE_DISABLED.getCode(), ex.getCode());
|
||||
assertDoesNotThrow(() -> accessService.assertCatalogReadAllowed(100L));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,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.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||
@@ -14,6 +16,8 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.REPORT_NOT_OWN;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.SESSION_NOT_FOUND;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
@@ -58,9 +62,14 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
return createSessionWithQuestions(clientSessionId, questionCount, correctAnswer, tenantId, userId);
|
||||
}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer,
|
||||
Long fixtureTenantId, Long fixtureUserId) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setTenantId(fixtureTenantId);
|
||||
session.setUserId(fixtureUserId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
@@ -71,10 +80,12 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setTenantId(fixtureTenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
String questionPrefix = fixtureTenantId.equals(tenantId) && fixtureUserId.equals(userId)
|
||||
? "q-" : "q-" + fixtureTenantId + "-" + fixtureUserId + "-";
|
||||
q.setQuestionId(questionPrefix + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
@@ -233,6 +244,65 @@ public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
assertEquals(1, wqs.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIsolateSessionsReportsAndWrongQuestionsAcrossTwoTenantsAndStudents() {
|
||||
Long[][] owners = {{11L, 101L}, {11L, 102L}, {22L, 201L}, {22L, 202L}};
|
||||
java.util.Map<String, SessionFixture> fixtures = new java.util.LinkedHashMap<>();
|
||||
java.util.Map<String, PracticeSubmitRespVO> reports = new java.util.LinkedHashMap<>();
|
||||
|
||||
for (Long[] owner : owners) {
|
||||
Long fixtureTenantId = owner[0];
|
||||
Long fixtureUserId = owner[1];
|
||||
String key = fixtureTenantId + ":" + fixtureUserId;
|
||||
SessionFixture fixture = createSessionWithQuestions(
|
||||
"isolation-" + key, 1, "B", fixtureTenantId, fixtureUserId);
|
||||
answerQuestion(fixture.questions().get(0).getId(), "A");
|
||||
PracticeSubmitRespVO report = service.submitSession(
|
||||
createSubmitReq(fixture.sessionId(), "submit-" + key, 1),
|
||||
fixtureUserId, fixtureTenantId);
|
||||
fixtures.put(key, fixture);
|
||||
reports.put(key, report);
|
||||
}
|
||||
|
||||
assertEquals(4, reports.values().stream().map(PracticeSubmitRespVO::getReportId).distinct().count());
|
||||
assertEquals(4, wrongQuestionMapper.selectList().size());
|
||||
|
||||
for (Long[] owner : owners) {
|
||||
Long fixtureTenantId = owner[0];
|
||||
Long fixtureUserId = owner[1];
|
||||
String key = fixtureTenantId + ":" + fixtureUserId;
|
||||
SessionFixture fixture = fixtures.get(key);
|
||||
PracticeSubmitRespVO report = reports.get(key);
|
||||
|
||||
assertEquals(report.getReportId(),
|
||||
service.getReport(fixture.sessionId(), fixtureUserId, fixtureTenantId).getReportId());
|
||||
PageResult<PracticeSubmitRespVO> history =
|
||||
service.getReportHistory(fixtureUserId, fixtureTenantId, 1, 10);
|
||||
assertEquals(1L, history.getTotal());
|
||||
assertEquals(fixture.sessionId(), history.getList().get(0).getSessionId());
|
||||
|
||||
List<WrongQuestionDO> ownWrongQuestions = wrongQuestionMapper.selectList().stream()
|
||||
.filter(wq -> fixtureTenantId.equals(wq.getTenantId()) && fixtureUserId.equals(wq.getUserId()))
|
||||
.toList();
|
||||
assertEquals(1, ownWrongQuestions.size());
|
||||
WrongQuestionDO wrongQuestion = ownWrongQuestions.get(0);
|
||||
assertEquals(fixture.sessionId(), wrongQuestion.getLastSessionId());
|
||||
assertEquals(report.getReportId(), wrongQuestion.getLastReportId());
|
||||
|
||||
Long otherUserSameTenant = java.util.Arrays.stream(owners)
|
||||
.filter(candidate -> fixtureTenantId.equals(candidate[0]) && !fixtureUserId.equals(candidate[1]))
|
||||
.findFirst().orElseThrow()[1];
|
||||
ServiceException crossStudent = assertThrows(ServiceException.class,
|
||||
() -> service.getReport(fixture.sessionId(), otherUserSameTenant, fixtureTenantId));
|
||||
assertEquals(REPORT_NOT_OWN.getCode(), crossStudent.getCode());
|
||||
|
||||
Long otherTenant = fixtureTenantId.equals(11L) ? 22L : 11L;
|
||||
ServiceException crossTenant = assertThrows(ServiceException.class,
|
||||
() -> service.getReport(fixture.sessionId(), fixtureUserId, otherTenant));
|
||||
assertEquals(SESSION_NOT_FOUND.getCode(), crossTenant.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== wrong_question_id populated in idempotency guard ==========
|
||||
|
||||
@Test
|
||||
|
||||
@@ -300,7 +300,26 @@ class QuestionCatalogServiceImplTest {
|
||||
assertEquals(3L, result.getTotal());
|
||||
}
|
||||
|
||||
// ========== Practice config validation ==========
|
||||
@Test
|
||||
void shouldCapPracticeBoundsByEligibleCount() {
|
||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||
.eligibleCount(5)
|
||||
.totalCount(5)
|
||||
.minQuestions(10)
|
||||
.maxQuestions(100)
|
||||
.suggestedCount(10)
|
||||
.build();
|
||||
when(provider.getPracticeBlueprint(eq("col1"), isNull(), isNull(), isNull())).thenReturn(bp);
|
||||
|
||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||
req.setCollectionId("col1");
|
||||
req.setQuestionCount(1);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.previewPracticeConfig(req));
|
||||
assertEquals(INSUFFICIENT_ELIGIBLE_QUESTIONS.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldReturnNormalizedPracticePreview() {
|
||||
@@ -446,10 +465,8 @@ class QuestionCatalogServiceImplTest {
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Fix #8: null list/page result handling ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResult() {
|
||||
void shouldRejectNullPageResult() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(null);
|
||||
|
||||
@@ -457,14 +474,12 @@ class QuestionCatalogServiceImplTest {
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
assertEquals(0L, result.getTotal());
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.pageQuestions(req));
|
||||
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNullPageResultItems() {
|
||||
void shouldRejectNullPageResultItems() {
|
||||
when(provider.listQuestions(any(), any(), any(), any(), eq(1), eq(20)))
|
||||
.thenReturn(CatalogQuestionPageResult.builder()
|
||||
.items(null)
|
||||
@@ -475,9 +490,8 @@ class QuestionCatalogServiceImplTest {
|
||||
req.setPageNo(1);
|
||||
req.setPageSize(20);
|
||||
|
||||
PageResult<SafeQuestionRespVO> result = service.pageQuestions(req);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getList().isEmpty());
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.pageQuestions(req));
|
||||
assertEquals(CATALOG_UPSTREAM_UNAVAILABLE.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ========== Two tenant contexts ==========
|
||||
|
||||
Reference in New Issue
Block a user