forked from wangziqi/ruoyi-vue-pro
feat(education): add manual question collections
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.collection;
|
||||
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionDraftReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.collection.vo.QuestionCollectionMembershipReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringCommand;
|
||||
import cn.iocoder.yudao.module.education.service.collection.authoring.QuestionCollectionAuthoringService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = QuestionCollectionAuthoringControllerContractTest.Config.class)
|
||||
class QuestionCollectionAuthoringControllerContractTest {
|
||||
@jakarta.annotation.Resource QuestionCollectionAuthoringController controller;
|
||||
@jakarta.annotation.Resource RecordingService service;
|
||||
@jakarta.annotation.Resource MutableSecurity security;
|
||||
@BeforeEach void setUp() {
|
||||
security.permissions.clear();
|
||||
LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of()));
|
||||
}
|
||||
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test void permissionsAreIndependentAndActorIsServerDerived() {
|
||||
security.permissions.add("education:collection:author");
|
||||
assertEquals(101L, controller.create(draft()).getData());
|
||||
assertEquals(1, controller.replaceMembership(101L, membership()).getData());
|
||||
assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 1));
|
||||
security.permissions.clear(); security.permissions.add("education:collection:publish");
|
||||
assertThrows(AccessDeniedException.class, () -> controller.create(draft()));
|
||||
assertEquals(2, controller.activate(101L, 1).getData());
|
||||
assertEquals(7L, service.actorId);
|
||||
security.permissions.clear(); security.permissions.add("education:collection:archive");
|
||||
assertEquals(3, controller.archive(101L, 2).getData());
|
||||
}
|
||||
|
||||
private QuestionCollectionDraftReqVO draft() {
|
||||
QuestionCollectionDraftReqVO r = new QuestionCollectionDraftReqVO();
|
||||
r.setNodeId(100L); r.setName("manual-set"); r.setTitle("Manual set"); r.setCollectionType("MANUAL");
|
||||
return r;
|
||||
}
|
||||
private QuestionCollectionMembershipReqVO membership() {
|
||||
QuestionCollectionMembershipReqVO r = new QuestionCollectionMembershipReqVO();
|
||||
r.setExpectedAuthoringVersion(0); r.setQuestionIds(List.of(11L, 12L)); return r;
|
||||
}
|
||||
@Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config {
|
||||
@Bean("ss") MutableSecurity security() { return new MutableSecurity(); }
|
||||
@Bean RecordingService service() { return new RecordingService(); }
|
||||
@Bean QuestionCollectionAuthoringController controller(QuestionCollectionAuthoringService s) { return new QuestionCollectionAuthoringController(s); }
|
||||
}
|
||||
static class MutableSecurity implements SecurityFrameworkService {
|
||||
final Set<String> permissions = new HashSet<>();
|
||||
public boolean hasPermission(String p) { return permissions.contains(p); }
|
||||
public boolean hasAnyPermissions(String... p) { return Arrays.stream(p).anyMatch(this::hasPermission); }
|
||||
public boolean hasRole(String r) { return false; } public boolean hasAnyRoles(String... r) { return false; }
|
||||
public boolean hasScope(String s) { return false; } public boolean hasAnyScopes(String... s) { return false; }
|
||||
}
|
||||
static class RecordingService implements QuestionCollectionAuthoringService {
|
||||
Long actorId;
|
||||
public Long createDraft(QuestionCollectionAuthoringCommand c) { return 101L; }
|
||||
public int reviseDraft(Long id, QuestionCollectionAuthoringCommand c) { return c.expectedAuthoringVersion() + 1; }
|
||||
public int replaceMembership(Long id, List<Long> q, int v) { return v + 1; }
|
||||
public int activate(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
public int archive(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package cn.iocoder.yudao.module.education.service.collection.authoring;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import java.util.List;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Import({QuestionCollectionAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringServiceImpl.class, cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleServiceImpl.class, EducationProperties.class})
|
||||
@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"})
|
||||
class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
|
||||
@Resource EducationProperties properties;
|
||||
@Resource QuestionCollectionAuthoringService service;
|
||||
@Resource cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService nodeService;
|
||||
@Resource QuestionCollectionMapper collectionMapper;
|
||||
@Resource QuestionCollectionQuestionMapper membershipMapper;
|
||||
@Resource QuestionCollectionLifecycleAuditMapper auditMapper;
|
||||
@Resource ContentNodeLifecycleAuditMapper nodeAuditMapper;
|
||||
@Resource ContentEntryMapper entryMapper;
|
||||
@Resource ContentNodeMapper nodeMapper;
|
||||
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
||||
@Resource QuestionMapper questionMapper;
|
||||
private Long activeNodeId;
|
||||
private List<Long> publishedQuestionIds;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); entry.setScope("TENANT_OWNED");
|
||||
entry.setEntryKey("questions"); entry.setName("Questions"); entry.setEntryType("question"); entryMapper.insert(entry);
|
||||
activeNodeId = nodeService.createDraft(new cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand(
|
||||
100L, null, "Active node", null, "category", null, true, 0, null, null));
|
||||
nodeService.activate(activeNodeId, 0, 7L);
|
||||
Long first = createPublishedQuestion();
|
||||
Long second = createPublishedQuestion();
|
||||
publishedQuestionIds = List.of(first, second);
|
||||
}
|
||||
@AfterEach void clear() { TenantContextHolder.clear(); }
|
||||
|
||||
@Test void draftMembershipActivationAndArchiveGateOnlyCollectionRoute() {
|
||||
Long id = service.createDraft(command(null));
|
||||
assertTrue(collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).isEmpty());
|
||||
assertEquals(1, service.replaceMembership(id, List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), 0));
|
||||
assertEquals(2, service.activate(id, 1, 7L));
|
||||
assertEquals(List.of(publishedQuestionIds.get(1), publishedQuestionIds.get(0)), membershipMapper.selectByCollectionId(10L, id).stream().map(QuestionCollectionQuestionDO::getQuestionId).toList());
|
||||
assertEquals(1, collectionMapper.selectActiveList(10L, 100L, activeNodeId, null, 10).size());
|
||||
assertNotNull(collectionMapper.selectAvailableForStudent(10L, id));
|
||||
assertEquals(2, collectionMapper.selectTenantOwnedById(10L, id).getQuestionCount());
|
||||
assertEquals(3, service.archive(id, 2, 7L));
|
||||
assertNull(collectionMapper.selectAvailableForStudent(10L, id));
|
||||
assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0)));
|
||||
assertEquals(2L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test void duplicateAndIneligibleMembershipFailClosedWithoutChangingVersion() {
|
||||
Long id = service.createDraft(command(null));
|
||||
ServiceException duplicate = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(0), publishedQuestionIds.get(0)), 0));
|
||||
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE.getCode(), duplicate.getCode());
|
||||
Long draftId = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
|
||||
"Draft", "choice", "easy", List.of(
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
|
||||
"A", "Explanation", null));
|
||||
ServiceException invalid = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(draftId), 0));
|
||||
assertEquals(QUESTION_COLLECTION_MEMBERSHIP_INVALID.getCode(), invalid.getCode());
|
||||
assertEquals(0, collectionMapper.selectTenantOwnedById(10L, id).getAuthoringVersion());
|
||||
}
|
||||
|
||||
@Test void unsupportedProviderFailsBeforePersistence() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null)));
|
||||
assertEquals(QUESTION_COLLECTION_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode());
|
||||
assertEquals(0L, collectionMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test void otherTenantCannotManageCollection() {
|
||||
Long id = service.createDraft(command(null));
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
|
||||
}
|
||||
|
||||
@Test void staleAndActiveMutationAreRejected() {
|
||||
Long id = service.createDraft(command(null));
|
||||
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); service.activate(id, 1, 7L);
|
||||
ServiceException active = assertThrows(ServiceException.class, () -> service.replaceMembership(id, List.of(publishedQuestionIds.get(1)), 2));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), active.getCode());
|
||||
ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 1, 7L));
|
||||
assertEquals(QUESTION_COLLECTION_AUTHORING_CONFLICT.getCode(), stale.getCode());
|
||||
}
|
||||
|
||||
private QuestionCollectionAuthoringCommand command(Integer version) {
|
||||
return new QuestionCollectionAuthoringCommand(activeNodeId, "manual", "Manual", "MANUAL", 30, "{\"tier\":\"vip\"}", 0, null, version);
|
||||
}
|
||||
private Long createPublishedQuestion() {
|
||||
Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
|
||||
"Question", "choice", "easy", List.of(
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
|
||||
"A", "Explanation", null));
|
||||
questionService.place(id, new cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand(activeNodeId, 0));
|
||||
questionService.publish(id, 7L);
|
||||
return id;
|
||||
}
|
||||
private QuestionDO question(Long id, Long tenant, String scope, String status, boolean published) {
|
||||
QuestionDO q = new QuestionDO(); q.setId(id); q.setTenantId(tenant); q.setScope(scope); q.setContentVersion(1);
|
||||
q.setStem("Question " + id); q.setType("choice"); q.setOptions("[{\"label\":\"A\",\"content\":\"Yes\"}]");
|
||||
q.setStatus(status); q.setIsPublished(published); q.setPlacementVersion(0); return q;
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090", "4100", "4110");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
-- PostgreSQL persistence test cleanup.
|
||||
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
|
||||
TRUNCATE TABLE
|
||||
education_question_collection_lifecycle_audit,
|
||||
education_content_node_lifecycle_audit,
|
||||
education_question_lifecycle_transition_token,
|
||||
education_question_lifecycle_audit,
|
||||
education_question_version,
|
||||
education_question_collection_question,
|
||||
education_question_collection,
|
||||
education_question,
|
||||
education_idempotency,
|
||||
education_favorite,
|
||||
|
||||
Reference in New Issue
Block a user