feat(education): add native question publication lifecycle

This commit is contained in:
2026-07-30 21:31:08 +08:00
parent 4db4a7d371
commit 34bc1fe41e
22 changed files with 2044 additions and 20 deletions

View File

@@ -0,0 +1,245 @@
package cn.iocoder.yudao.module.education.controller.admin.question;
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.question.vo.QuestionDraftCreateReqVO;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = QuestionAuthoringControllerContractTest.SecurityTestConfiguration.class)
class QuestionAuthoringControllerContractTest {
private static final Long ACTOR_ID = 7L;
private static final String AUTHOR_PERMISSION = "education:question:author";
private static final String PUBLISH_PERMISSION = "education:question:publish";
private static final String ARCHIVE_PERMISSION = "education:question:archive";
@Autowired
private QuestionAuthoringController controller;
@Autowired
private RecordingLifecycleService lifecycleService;
@Autowired
private MutableSecurityFrameworkService securityFrameworkService;
@BeforeEach
void setUp() {
lifecycleService.reset();
securityFrameworkService.reset();
LoginUser loginUser = new LoginUser();
loginUser.setId(ACTOR_ID);
loginUser.setTenantId(10L);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, List.of()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void authorPermissionDoesNotAllowPublishing() {
grant(AUTHOR_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.publish(101L));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void publishPermissionDoesNotAllowArchiving() {
grant(PUBLISH_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.archive(101L));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void archivePermissionDoesNotAllowDraftCreation() {
grant(ARCHIVE_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.createDraft(draftRequest()));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void authorPermissionAllowsDraftCreation() {
grant(AUTHOR_PERMISSION);
assertEquals(101L, controller.createDraft(draftRequest()).getData());
assertEquals("2 + 2 = ?", lifecycleService.createdCommand.stem());
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void publishPermissionAllowsPublishing() {
grant(PUBLISH_PERMISSION);
controller.publish(101L);
assertEquals(101L, lifecycleService.publishedQuestionId);
assertEquals(ACTOR_ID, lifecycleService.publishedByActorId);
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void archivePermissionAllowsArchiving() {
grant(ARCHIVE_PERMISSION);
controller.archive(101L);
assertEquals(101L, lifecycleService.archivedQuestionId);
assertEquals(ACTOR_ID, lifecycleService.archivedByActorId);
assertEquals(1, lifecycleService.totalCalls());
}
private void grant(String permission) {
securityFrameworkService.grant(permission);
}
private QuestionDraftCreateReqVO draftRequest() {
QuestionDraftCreateReqVO.Option optionA = new QuestionDraftCreateReqVO.Option();
optionA.setLabel("A");
optionA.setContent("4");
optionA.setOrder(1D);
QuestionDraftCreateReqVO.Option optionB = new QuestionDraftCreateReqVO.Option();
optionB.setLabel("B");
optionB.setContent("5");
optionB.setOrder(2D);
QuestionDraftCreateReqVO request = new QuestionDraftCreateReqVO();
request.setStem("2 + 2 = ?");
request.setType("choice");
request.setDifficulty("easy");
request.setOptions(List.of(optionA, optionB));
request.setCorrectAnswer("A");
return request;
}
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
static class SecurityTestConfiguration {
@Bean("ss")
MutableSecurityFrameworkService securityFrameworkService() {
return new MutableSecurityFrameworkService();
}
@Bean
RecordingLifecycleService tenantQuestionLifecycleService() {
return new RecordingLifecycleService();
}
@Bean
QuestionAuthoringController questionAuthoringController(
TenantQuestionLifecycleService tenantQuestionLifecycleService) {
return new QuestionAuthoringController(tenantQuestionLifecycleService);
}
}
static class MutableSecurityFrameworkService implements SecurityFrameworkService {
private final Set<String> grantedPermissions = new HashSet<>();
void grant(String permission) {
grantedPermissions.add(permission);
}
void reset() {
grantedPermissions.clear();
}
@Override
public boolean hasPermission(String permission) {
return grantedPermissions.contains(permission);
}
@Override
public boolean hasAnyPermissions(String... permissions) {
return java.util.Arrays.stream(permissions).anyMatch(this::hasPermission);
}
@Override
public boolean hasRole(String role) {
return false;
}
@Override
public boolean hasAnyRoles(String... roles) {
return false;
}
@Override
public boolean hasScope(String scope) {
return false;
}
@Override
public boolean hasAnyScopes(String... scope) {
return false;
}
}
static class RecordingLifecycleService implements TenantQuestionLifecycleService {
private QuestionDraftCommand createdCommand;
private Long publishedQuestionId;
private Long publishedByActorId;
private Long archivedQuestionId;
private Long archivedByActorId;
void reset() {
createdCommand = null;
publishedQuestionId = null;
publishedByActorId = null;
archivedQuestionId = null;
archivedByActorId = null;
}
int totalCalls() {
int count = createdCommand != null ? 1 : 0;
count += publishedQuestionId != null ? 1 : 0;
count += archivedQuestionId != null ? 1 : 0;
return count;
}
@Override
public Long createDraft(QuestionDraftCommand command) {
createdCommand = command;
return 101L;
}
@Override
public void publish(Long questionId, Long actorId) {
publishedQuestionId = questionId;
publishedByActorId = actorId;
}
@Override
public void archive(Long questionId, Long actorId) {
archivedQuestionId = questionId;
archivedByActorId = actorId;
}
}
}

View File

@@ -0,0 +1,210 @@
package cn.iocoder.yudao.module.education.service.question.authoring;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
import cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Import({TenantQuestionLifecycleServiceImpl.class, JavaCatalogProvider.class,
QuestionCatalogServiceImpl.class, EducationProperties.class})
@TestPropertySource(properties = {
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ"
})
class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource private TenantQuestionLifecycleService lifecycleService;
@Resource private QuestionCatalogService questionCatalogService;
@Resource private QuestionLifecycleAuditMapper auditMapper;
@Resource private QuestionMapper questionMapper;
@Resource private QuestionVersionMapper versionMapper;
@Resource private PlatformTransactionManager transactionManager;
@BeforeEach
void setUpTenant() {
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void clearTenant() {
TenantContextHolder.clear();
}
@Test
void shouldMoveDraftThroughPublishAndArchiveAcrossStudentReadSeam() {
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
"2 + 2 = ?", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "4", 1D),
new QuestionDraftCommand.QuestionDraftOption("B", "5", 2D)),
"A", "secret explanation", "secret analysis"));
assertQuestionNotVisible(id);
lifecycleService.publish(id, 7L);
SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id));
assertEquals("2 + 2 = ?", published.getStem());
assertEquals(2, published.getOptions().size());
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published)
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
lifecycleService.archive(id, 7L);
assertQuestionNotVisible(id);
assertEquals(2L, auditMapper.selectCount());
}
@Test
void shouldRollbackPublicationWhenDurableAuditFails() {
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
"Capital of France?", "text", "easy", List.of(),
"Paris", null, null));
assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null));
assertEquals("DRAFT", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(0L, auditMapper.selectCount());
assertQuestionNotVisible(id);
}
@Test
void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException {
Long id = lifecycleService.createDraft(questionCommand("Concurrent publish"));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicInteger successCount = new AtomicInteger();
AtomicReference<Throwable> firstError = new AtomicReference<>();
AtomicReference<Throwable> secondError = new AtomicReference<>();
Thread first = publishThread(id, 7L, ready, go, successCount, firstError);
Thread second = publishThread(id, 8L, ready, go, successCount, secondError);
first.start();
second.start();
assertTrue(ready.await(5, TimeUnit.SECONDS));
go.countDown();
first.join(10000);
second.join(10000);
assertFalse(first.isAlive());
assertFalse(second.isAlive());
assertEquals(1, successCount.get());
Throwable loserError = firstError.get() != null ? firstError.get() : secondError.get();
ServiceException conflict = assertInstanceOf(ServiceException.class, loserError);
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), conflict.getCode());
assertEquals("PUBLISHED", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(1L, auditMapper.selectCount());
}
@Test
void shouldRejectCrossTenantPublishAndArchive() {
Long id = lifecycleService.createDraft(questionCommand("Tenant isolated"));
TenantContextHolder.setTenantId(20L);
assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L));
TenantContextHolder.setTenantId(10L);
lifecycleService.publish(id, 7L);
TenantContextHolder.setTenantId(20L);
assertAuthoringNotFound(() -> lifecycleService.archive(id, 8L));
TenantContextHolder.setTenantId(10L);
assertEquals("PUBLISHED", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(1L, auditMapper.selectCount());
}
@Test
void shouldRejectPublicQuestionFromTenantLifecycleService() {
Long publicQuestionId = createPublicDraft();
assertAuthoringNotFound(() -> lifecycleService.publish(publicQuestionId, 7L));
assertEquals(0L, auditMapper.selectCount());
}
private void assertQuestionNotVisible(Long id) {
ServiceException ex = assertThrows(ServiceException.class,
() -> questionCatalogService.getQuestion(String.valueOf(id)));
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
}
private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) {
ServiceException ex = assertThrows(ServiceException.class, executable);
assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode());
}
private Thread publishThread(Long questionId, Long actorId, CountDownLatch ready, CountDownLatch go,
AtomicInteger successCount, AtomicReference<Throwable> error) {
return new Thread(() -> {
TenantContextHolder.setTenantId(10L);
ready.countDown();
try {
assertTrue(go.await(5, TimeUnit.SECONDS));
lifecycleService.publish(questionId, actorId);
successCount.incrementAndGet();
} catch (Throwable throwable) {
error.set(throwable);
} finally {
TenantContextHolder.clear();
}
});
}
private Long createPublicDraft() {
return TenantUtils.executeIgnore(() -> new TransactionTemplate(transactionManager).execute(status -> {
QuestionDO question = new QuestionDO();
question.setTenantId(0L);
question.setScope("PUBLIC");
question.setContentVersion(1);
question.setStem("Public draft");
question.setType("text");
question.setOptions("[]");
question.setCorrectAnswer("answer");
question.setStatus("DRAFT");
question.setIsPublished(false);
question.setSortOrder(0);
questionMapper.insert(question);
QuestionVersionDO version = new QuestionVersionDO();
version.setTenantId(0L);
version.setScope("PUBLIC");
version.setQuestionId(question.getId());
version.setVersionNumber(1);
version.setStem(question.getStem());
version.setType(question.getType());
version.setOptions(question.getOptions());
version.setCorrectAnswer(question.getCorrectAnswer());
versionMapper.insert(version);
return question.getId();
}));
}
private QuestionDraftCommand questionCommand(String stem) {
return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null);
}
}

View File

@@ -0,0 +1,213 @@
package cn.iocoder.yudao.module.education.service.question.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.QuestionDO;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.stream.Stream;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TenantQuestionLifecycleServiceImplTest {
@Mock private QuestionMapper questionMapper;
@Mock private QuestionVersionMapper versionMapper;
@Mock private QuestionLifecycleAuditMapper auditMapper;
private EducationProperties properties;
private TenantQuestionLifecycleServiceImpl service;
@BeforeEach
void setUp() {
properties = new EducationProperties();
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper);
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@Test
void shouldFailBeforeDatabaseAccessInScalarMode() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L));
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
verifyNoInteractions(questionMapper, versionMapper, auditMapper);
}
@Test
void shouldCreateTenantOwnedDraftAndFirstImmutableVersion() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
doAnswer(invocation -> {
QuestionDO question = invocation.getArgument(0);
question.setId(101L);
return 1;
}).when(questionMapper).insert((QuestionDO) any(QuestionDO.class));
Long id = service.createDraft(command());
assertEquals(101L, id);
ArgumentCaptor<QuestionDO> questionCaptor = ArgumentCaptor.forClass(QuestionDO.class);
verify(questionMapper).insert((QuestionDO) questionCaptor.capture());
QuestionDO insertedQuestion = questionCaptor.getValue();
assertEquals(10L, insertedQuestion.getTenantId());
assertEquals("TENANT_OWNED", insertedQuestion.getScope());
assertEquals("DRAFT", insertedQuestion.getStatus());
assertFalse(insertedQuestion.getIsPublished());
assertEquals(1, insertedQuestion.getContentVersion());
ArgumentCaptor<cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO> versionCaptor =
ArgumentCaptor.forClass(
cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO.class);
verify(versionMapper).insert((cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO)
versionCaptor.capture());
assertEquals(101L, versionCaptor.getValue().getQuestionId());
assertEquals(10L, versionCaptor.getValue().getTenantId());
assertEquals(1, versionCaptor.getValue().getVersionNumber());
verifyNoInteractions(auditMapper);
}
@Test
void shouldPublishWithCasAndAppendAudit() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1);
service.publish(101L, 7L);
ArgumentCaptor<cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO> auditCaptor =
ArgumentCaptor.forClass(
cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO.class);
verify(auditMapper).insert((cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO)
auditCaptor.capture());
assertEquals(10L, auditCaptor.getValue().getTenantId());
assertEquals(7L, auditCaptor.getValue().getActorId());
assertEquals("DRAFT", auditCaptor.getValue().getFromStatus());
assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus());
}
@Test
void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
doAnswer(invocation -> {
QuestionDO question = invocation.getArgument(0);
question.setId(102L);
return 1;
}).when(questionMapper).insert((QuestionDO) any(QuestionDO.class));
QuestionDraftCommand command = new QuestionDraftCommand("Pick", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "first", null),
new QuestionDraftCommand.QuestionDraftOption("B", "second", null)),
"A", null, null);
service.createDraft(command);
ArgumentCaptor<QuestionDO> captor = ArgumentCaptor.forClass(QuestionDO.class);
verify(questionMapper).insert((QuestionDO) captor.capture());
assertTrue(captor.getValue().getOptions().contains("\"order\":1.0"));
assertTrue(captor.getValue().getOptions().contains("\"order\":2.0"));
}
@Test
void shouldRejectOptionAnswerThatDoesNotExist() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
draft.setCorrectAnswer("C");
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
}
@Test
void shouldRejectIncompleteDraftBeforeLifecycleUpdate() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
draft.setCorrectAnswer(null);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
verifyNoInteractions(auditMapper);
}
@ParameterizedTest(name = "{0} from {1}")
@MethodSource("illegalLifecycleTransitions")
void shouldRejectIllegalLifecycleTransition(String operation, String currentStatus) {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO question = draft();
question.setStatus(currentStatus);
question.setIsPublished("PUBLISHED".equals(currentStatus));
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(question);
ServiceException ex = assertThrows(ServiceException.class, () -> {
if ("publish".equals(operation)) {
service.publish(101L, 7L);
} else {
service.archive(101L, 7L);
}
});
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
verifyNoInteractions(auditMapper);
}
private static Stream<Arguments> illegalLifecycleTransitions() {
return Stream.of(
Arguments.of("publish", "PUBLISHED"),
Arguments.of("publish", "ARCHIVED"),
Arguments.of("archive", "DRAFT"),
Arguments.of("archive", "ARCHIVED"));
}
private QuestionDraftCommand command() {
return new QuestionDraftCommand("2 + 2 = ?", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "4", 1D),
new QuestionDraftCommand.QuestionDraftOption("B", "5", 2D)),
"A", "basic", null);
}
private QuestionDO draft() {
QuestionDO question = new QuestionDO();
question.setId(101L);
question.setTenantId(10L);
question.setScope("TENANT_OWNED");
question.setContentVersion(1);
question.setStem("2 + 2 = ?");
question.setType("choice");
question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," +
"{\"label\":\"B\",\"content\":\"5\",\"order\":2}]");
question.setCorrectAnswer("A");
question.setStatus("DRAFT");
question.setIsPublished(false);
return question;
}
}

View File

@@ -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");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
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");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
@@ -261,6 +261,261 @@ class EducationFlywayMigrationIntegrationTest {
.hasMessageContaining("education_school.region_id");
}
@Test
void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException {
String schema = createSchema("question_lifecycle");
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_question'
AND column_name IN ('status', 'is_published')
ORDER BY column_name
"""))
.containsExactly("false", "'DRAFT'::character varying");
assertThat(queryStrings(schema, """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name IN (
'education_question_version',
'education_question_lifecycle_audit')
ORDER BY table_name
"""))
.containsExactly(
"education_question_lifecycle_audit",
"education_question_version");
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options)
VALUES (100, 10, 'TENANT_OWNED', 'Draft question', 'fill', '[]');
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (10, 'TENANT_OWNED', 100, 1, 'Draft question', 'fill', '[]');
""");
assertThat(queryStrings(schema,
"SELECT status || ':' || is_published FROM education_question WHERE id = 100"))
.containsExactly("DRAFT:false");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question
(tenant_id, scope, stem, type, options, status, is_published)
VALUES (10, 'TENANT_OWNED', 'Bypass draft', 'fill', '[]', 'PUBLISHED', true);
"""))
.hasMessageContaining("new questions must start in DRAFT");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 100, 1, 7, 'DRAFT', 'PUBLISHED');
"""))
.hasMessageContaining("question lifecycle audit must accompany its state transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'ARCHIVED', is_published = false
WHERE id = 100;
"""))
.hasMessageContaining("invalid question lifecycle transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question_version SET stem = 'mutated' WHERE question_id = 100;
"""))
.hasMessageContaining("question versions are immutable");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question SET stem = 'mutated projection' WHERE id = 100;
"""))
.hasMessageContaining("question content must change through immutable versions");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 100;
"""))
.hasMessageContaining("question lifecycle transition requires transactional audit");
execute(schema, """
DO $lifecycle$
BEGIN
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 100;
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 100, 1, 7, 'DRAFT', 'PUBLISHED');
END
$lifecycle$;
""");
assertThat(queryStrings(schema,
"SELECT status || ':' || is_published FROM education_question WHERE id = 100"))
.containsExactly("PUBLISHED:true");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question_lifecycle_audit SET actor_id = 8 WHERE question_id = 100;
"""))
.hasMessageContaining("question lifecycle audit is append-only");
assertThatThrownBy(() -> execute(schema, """
DELETE FROM education_question_lifecycle_audit WHERE question_id = 100;
"""))
.hasMessageContaining("question lifecycle audit is append-only");
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options)
VALUES (200, 0, 'PUBLIC', 'Public draft', 'fill', '[]');
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (0, 'PUBLIC', 200, 1, 'Public draft', 'fill', '[]');
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 200;
"""))
.hasMessageContaining("PUBLIC question lifecycle is not managed by tenant authoring");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (10, 'TENANT_OWNED', 200, 2, 'Wrong owner', 'fill', '[]');
"""))
.hasMessageContaining("question version ownership must equal question ownership");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (0, 'PUBLIC', 100, 2, 'Wrong owner', 'fill', '[]');
"""))
.hasMessageContaining("question version ownership must equal question ownership");
}
@Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Published', 'fill', '[]', 'ok', 'PUBLISHED', true),
(102, 10, 'TENANT_OWNED', 'Hidden', 'fill', '[]', NULL, 'HIDDEN', true),
(103, 10, 'TENANT_OWNED', 'Inactive', 'fill', '[]', NULL, 'INACTIVE', false),
(104, 10, 'TENANT_OWNED', 'False publication', 'fill', '[]', NULL, 'PUBLISHED', false),
(105, 10, 'TENANT_OWNED', 'Draft', 'fill', '[]', NULL, 'DRAFT', true);
""");
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT id || ':' || status || ':' || is_published
FROM education_question
ORDER BY id
"""))
.containsExactly(
"101:PUBLISHED:true",
"102:ARCHIVED:false",
"103:ARCHIVED:false",
"104:ARCHIVED:false",
"105:DRAFT:false");
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_question_version"))
.isEqualTo(5L);
assertThatThrownBy(() -> execute(schema, """
DO $late_audit$
BEGIN
UPDATE education_question SET updater = updater WHERE id = 101;
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 101, 1, 7, 'DRAFT', 'PUBLISHED');
END
$late_audit$;
"""))
.hasMessageContaining("question lifecycle audit must accompany its state transition");
}
@Test
void shouldFailClosedForUnsafeHistoricalPublishedQuestion() throws SQLException {
String schema = createSchema("unsafe_question_history");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe published', 'choice',
'[{"label":"A","content":"One","order":1},{"label":"B","content":"Two","order":2}]',
'Z', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4080' AND success = TRUE"))
.isZero();
}
@Test
void shouldFailClosedForHistoricalTrimmedDuplicateOptionLabel() throws SQLException {
String schema = createSchema("unsafe_trimmed_option_label");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe labels', 'choice',
'[{"label":"A","content":"One","order":1},{"label":" A ","content":"Two","order":2}]',
'A', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
}
@Test
void shouldFailClosedForHistoricalDuplicateOptionOrder() throws SQLException {
String schema = createSchema("unsafe_duplicate_option_order");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe order', 'choice',
'[{"label":"A","content":"One","order":1},{"label":"B","content":"Two","order":1.0}]',
'A', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
}
@Test
void shouldProvisionAssignableSystemRbacPermissionsWhenPlatformMenuExists() throws SQLException {
String schema = createSchema("question_permissions");
configureFlyway(schema, false).target("4070").load().migrate();
createSystemMenuFixture(schema);
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT permission
FROM system_menu
WHERE id BETWEEN 6801 AND 6804
ORDER BY id
"""))
.containsExactly(
"education:capability",
"education:question:author",
"education:question:publish",
"education:question:archive");
}
@Test
void shouldFailClosedWhenEducationPermissionSeedIsSoftDeleted() throws SQLException {
String schema = createSchema("question_permission_conflict");
configureFlyway(schema, false).target("4070").load().migrate();
createSystemMenuFixture(schema);
execute(schema, """
INSERT INTO system_menu
(id, name, permission, type, sort, parent_id, status, deleted)
VALUES (6802, 'Deleted author permission', 'education:question:author',
3, 2, 6800, 0, 1);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("Education System RBAC seed IDs conflict");
}
@Test
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
String schema = createSchema("catalog_triggers");
@@ -305,6 +560,8 @@ class EducationFlywayMigrationIntegrationTest {
('education_question_collection_question', 'trg_education_qcq_collection_reference_scope',
'collection_id|education_question_collection|'),
('education_question_collection_question', 'trg_education_qcq_question_reference_scope',
'question_id|education_question|'),
('education_question_version', 'trg_education_question_version_reference_scope',
'question_id|education_question|')
)
SELECT COUNT(*)
@@ -324,7 +581,7 @@ class EducationFlywayMigrationIntegrationTest {
WHERE replace(encode(trigger.tgargs, 'escape'), $$\\000$$, '|') =
expected.trigger_arguments
"""))
.isEqualTo(19L);
.isEqualTo(20L);
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM pg_trigger trigger
@@ -334,14 +591,22 @@ class EducationFlywayMigrationIntegrationTest {
AND trigger.tgname LIKE 'trg_education_%_scope_immutable'
AND NOT trigger.tgisinternal
"""))
.isEqualTo(11L);
.isEqualTo(12L);
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM information_schema.routine_privileges
WHERE specific_schema = current_schema()
AND routine_name IN (
'education_check_reference_scope',
'education_prevent_catalog_scope_change')
'education_prevent_catalog_scope_change',
'education_prevent_question_version_mutation',
'education_enforce_question_lifecycle_transition',
'education_prevent_question_lifecycle_audit_mutation',
'education_prevent_question_content_projection_mutation',
'education_require_question_lifecycle_audit',
'education_check_question_version_ownership',
'education_validate_question_lifecycle_audit_insert',
'education_question_answer_key_is_valid')
AND grantee = 'PUBLIC'
AND privilege_type = 'EXECUTE'
"""))
@@ -405,6 +670,32 @@ class EducationFlywayMigrationIntegrationTest {
.isZero();
}
private void createSystemMenuFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE system_menu (
id BIGINT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
permission VARCHAR(100) NOT NULL DEFAULT '',
type SMALLINT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
parent_id BIGINT NOT NULL DEFAULT 0,
path VARCHAR(200) DEFAULT '',
icon VARCHAR(100) DEFAULT '#',
component VARCHAR(255),
component_name VARCHAR(255),
status SMALLINT NOT NULL DEFAULT 0,
visible BOOLEAN NOT NULL DEFAULT true,
keep_alive BOOLEAN NOT NULL DEFAULT true,
always_show BOOLEAN NOT NULL DEFAULT true,
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 SMALLINT NOT NULL DEFAULT 0
);
""");
}
private void createCompatibleManualPracticeFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE education_practice_session (

View File

@@ -1,6 +1,10 @@
-- PostgreSQL persistence test cleanup.
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
TRUNCATE TABLE
education_question_lifecycle_transition_token,
education_question_lifecycle_audit,
education_question_version,
education_question,
education_idempotency,
education_favorite,
education_wrong_question_idempotency,