forked from wangziqi/ruoyi-vue-pro
feat(education): add native question publication lifecycle
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user