feat(education): add tenant question placement

This commit is contained in:
2026-07-30 22:28:08 +08:00
parent 34bc1fe41e
commit 2a40cbd69e
21 changed files with 813 additions and 44 deletions

View File

@@ -3,7 +3,9 @@ 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.controller.admin.question.vo.QuestionPlacementReqVO;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -32,6 +34,7 @@ class QuestionAuthoringControllerContractTest {
private static final Long ACTOR_ID = 7L;
private static final String AUTHOR_PERMISSION = "education:question:author";
private static final String CLASSIFY_PERMISSION = "education:question:classify";
private static final String PUBLISH_PERMISSION = "education:question:publish";
private static final String ARCHIVE_PERMISSION = "education:question:archive";
@@ -67,6 +70,14 @@ class QuestionAuthoringControllerContractTest {
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void authorPermissionDoesNotAllowPlacement() {
grant(AUTHOR_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.place(101L, null));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void publishPermissionDoesNotAllowArchiving() {
grant(PUBLISH_PERMISSION);
@@ -75,6 +86,14 @@ class QuestionAuthoringControllerContractTest {
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void classifyPermissionDoesNotAllowPublishing() {
grant(CLASSIFY_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.publish(101L));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void archivePermissionDoesNotAllowDraftCreation() {
grant(ARCHIVE_PERMISSION);
@@ -92,6 +111,19 @@ class QuestionAuthoringControllerContractTest {
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void classifyPermissionAllowsPlacement() {
grant(CLASSIFY_PERMISSION);
QuestionPlacementReqVO request = new QuestionPlacementReqVO();
request.setNodeId(201L);
request.setExpectedPlacementVersion(0);
assertEquals(1, controller.place(101L, request).getData());
assertEquals(101L, lifecycleService.placedQuestionId);
assertEquals(new QuestionPlacementCommand(201L, 0), lifecycleService.placementCommand);
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void publishPermissionAllowsPublishing() {
grant(PUBLISH_PERMISSION);
@@ -208,6 +240,8 @@ class QuestionAuthoringControllerContractTest {
private Long publishedByActorId;
private Long archivedQuestionId;
private Long archivedByActorId;
private Long placedQuestionId;
private QuestionPlacementCommand placementCommand;
void reset() {
createdCommand = null;
@@ -215,12 +249,15 @@ class QuestionAuthoringControllerContractTest {
publishedByActorId = null;
archivedQuestionId = null;
archivedByActorId = null;
placedQuestionId = null;
placementCommand = null;
}
int totalCalls() {
int count = createdCommand != null ? 1 : 0;
count += publishedQuestionId != null ? 1 : 0;
count += archivedQuestionId != null ? 1 : 0;
count += placedQuestionId != null ? 1 : 0;
return count;
}
@@ -230,6 +267,13 @@ class QuestionAuthoringControllerContractTest {
return 101L;
}
@Override
public int place(Long questionId, QuestionPlacementCommand command) {
placedQuestionId = questionId;
placementCommand = command;
return command.expectedPlacementVersion() + 1;
}
@Override
public void publish(Long questionId, Long actorId) {
publishedQuestionId = questionId;

View File

@@ -5,8 +5,13 @@ 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.controller.app.question.vo.QuestionPageReqVO;
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.dataobject.catalog.ContentEntryDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
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;
@@ -46,10 +51,19 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
@Resource private QuestionMapper questionMapper;
@Resource private QuestionVersionMapper versionMapper;
@Resource private PlatformTransactionManager transactionManager;
@Resource private ContentEntryMapper contentEntryMapper;
@Resource private ContentNodeMapper contentNodeMapper;
@BeforeEach
void setUpTenant() {
TenantContextHolder.setTenantId(10L);
TenantUtils.executeIgnore(() -> {
insertEntry(100L, 10L, "TENANT_OWNED", "questions", "Questions");
insertEntry(110L, 0L, "PUBLIC", "public-questions", "Public Questions");
insertNode(200L, 10L, "TENANT_OWNED", 100L, "Algebra");
insertNode(201L, 10L, "TENANT_OWNED", 100L, "Geometry");
insertNode(210L, 0L, "PUBLIC", 110L, "Public Algebra");
});
}
@AfterEach
@@ -66,6 +80,8 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
"A", "secret explanation", "secret analysis"));
assertQuestionNotVisible(id);
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)));
assertNodeQuestionCount(200L, 0L);
lifecycleService.publish(id, 7L);
SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id));
@@ -73,6 +89,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
assertEquals(2, published.getOptions().size());
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published)
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
assertNodeQuestionCount(200L, 1L);
lifecycleService.archive(id, 7L);
assertQuestionNotVisible(id);
@@ -84,6 +101,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
"Capital of France?", "text", "easy", List.of(),
"Paris", null, null));
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null));
@@ -95,6 +113,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
@Test
void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException {
Long id = lifecycleService.createDraft(questionCommand("Concurrent publish"));
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicInteger successCount = new AtomicInteger();
@@ -120,9 +139,41 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
assertEquals(1L, auditMapper.selectCount());
}
@Test
void shouldAllowOnlyOneConcurrentPlacementForExpectedVersion() throws InterruptedException {
Long id = lifecycleService.createDraft(questionCommand("Concurrent placement"));
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 = placementThread(id, 200L, ready, go, successCount, firstError);
Thread second = placementThread(id, 201L, 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_PLACEMENT_CONFLICT.getCode(), conflict.getCode());
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
assertTrue(stored.getNodeId().equals(200L) || stored.getNodeId().equals(201L));
assertEquals(1, stored.getPlacementVersion());
assertEquals("DRAFT", stored.getStatus());
assertEquals(0L, auditMapper.selectCount());
}
@Test
void shouldRejectCrossTenantPublishAndArchive() {
Long id = lifecycleService.createDraft(questionCommand("Tenant isolated"));
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
TenantContextHolder.setTenantId(20L);
assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L));
@@ -147,12 +198,67 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
assertEquals(0L, auditMapper.selectCount());
}
@Test
void shouldAllowTenantDraftPlacementOnPublicNode() {
Long id = lifecycleService.createDraft(questionCommand("Public classification"));
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(210L, 0)));
lifecycleService.publish(id, 7L);
assertNodeQuestionCount(210L, 1L);
}
@Test
void shouldHidePublishedQuestionsWhenPlacementNodeBecomesUnavailable() {
Long id = lifecycleService.createDraft(questionCommand("Hidden route"));
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
lifecycleService.publish(id, 7L);
assertNodeQuestionCount(200L, 1L);
ContentNodeDO update = new ContentNodeDO();
update.setId(200L);
update.setIsActive(false);
contentNodeMapper.updateById(update);
assertNodeQuestionCount(200L, 0L);
assertNotNull(questionCatalogService.getQuestion(String.valueOf(id)));
}
@Test
void shouldRejectCrossTenantPlacementWithoutPartialWrite() {
Long id = lifecycleService.createDraft(questionCommand("Tenant placement"));
TenantUtils.executeIgnore(() -> {
insertEntry(120L, 20L, "TENANT_OWNED", "other-questions", "Other Questions");
insertNode(220L, 20L, "TENANT_OWNED", 120L, "Other Algebra");
});
ServiceException ex = assertThrows(ServiceException.class,
() -> lifecycleService.place(id, new QuestionPlacementCommand(220L, 0)));
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
assertNull(stored.getNodeId());
assertEquals(0, stored.getPlacementVersion());
assertEquals(1L, versionMapper.selectCount());
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 assertNodeQuestionCount(Long nodeId, long expected) {
QuestionPageReqVO request = new QuestionPageReqVO();
request.setNodeId(String.valueOf(nodeId));
var page = questionCatalogService.pageQuestions(request);
assertEquals(expected, page.getTotal());
assertEquals(expected, page.getList().size());
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(page.getList())
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
}
private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) {
ServiceException ex = assertThrows(ServiceException.class, executable);
assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode());
@@ -175,6 +281,23 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
});
}
private Thread placementThread(Long questionId, Long nodeId, 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.place(questionId, new QuestionPlacementCommand(nodeId, 0));
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();
@@ -207,4 +330,26 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
private QuestionDraftCommand questionCommand(String stem) {
return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null);
}
private void insertEntry(Long id, Long tenantId, String scope, String key, String name) {
ContentEntryDO entry = new ContentEntryDO();
entry.setId(id);
entry.setTenantId(tenantId);
entry.setScope(scope);
entry.setEntryKey(key);
entry.setName(name);
entry.setEntryType("question");
contentEntryMapper.insert(entry);
}
private void insertNode(Long id, Long tenantId, String scope, Long entryId, String name) {
ContentNodeDO node = new ContentNodeDO();
node.setId(id);
node.setTenantId(tenantId);
node.setScope(scope);
node.setEntryId(entryId);
node.setName(name);
node.setNodeType("category");
contentNodeMapper.insert(node);
}
}

View File

@@ -3,7 +3,9 @@ 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.ContentNodeDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
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;
@@ -31,6 +33,7 @@ import static org.mockito.Mockito.*;
class TenantQuestionLifecycleServiceImplTest {
@Mock private QuestionMapper questionMapper;
@Mock private ContentNodeMapper contentNodeMapper;
@Mock private QuestionVersionMapper versionMapper;
@Mock private QuestionLifecycleAuditMapper auditMapper;
@@ -40,7 +43,8 @@ class TenantQuestionLifecycleServiceImplTest {
@BeforeEach
void setUp() {
properties = new EducationProperties();
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper);
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, contentNodeMapper,
versionMapper, auditMapper);
TenantContextHolder.setTenantId(10L);
}
@@ -56,7 +60,18 @@ class TenantQuestionLifecycleServiceImplTest {
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L));
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
verifyNoInteractions(questionMapper, versionMapper, auditMapper);
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
}
@Test
void shouldFailPlacementBeforeDatabaseAccessInScalarMode() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.place(101L, new QuestionPlacementCommand(201L, 0)));
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
}
@Test
@@ -95,7 +110,8 @@ class TenantQuestionLifecycleServiceImplTest {
void shouldPublishWithCasAndAppendAudit() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1);
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true, 1)).thenReturn(1);
service.publish(101L, 7L);
@@ -110,6 +126,82 @@ class TenantQuestionLifecycleServiceImplTest {
assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus());
}
@Test
void shouldPlaceDraftOnAvailableNodeWithCas() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
ContentNodeDO node = new ContentNodeDO();
node.setId(201L);
node.setTenantId(10L);
node.setScope("TENANT_OWNED");
node.setIsActive(true);
node.setIsHidden(false);
node.setIsSelectable(true);
QuestionDO draft = draft();
draft.setNodeId(null);
draft.setPlacementVersion(0);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(node);
when(questionMapper.updatePlacement(10L, 101L, 201L, 0)).thenReturn(1);
int placementVersion = service.place(101L, new QuestionPlacementCommand(201L, 0));
assertEquals(1, placementVersion);
verify(questionMapper).updatePlacement(10L, 101L, 201L, 0);
verifyNoInteractions(versionMapper, auditMapper);
}
@Test
void shouldRejectUnavailablePlacementTarget() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class,
() -> service.place(101L, new QuestionPlacementCommand(999L, 1)));
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
}
@Test
void shouldRejectStalePlacementVersionBeforeTargetLookup() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
ServiceException ex = assertThrows(ServiceException.class,
() -> service.place(101L, new QuestionPlacementCommand(202L, 0)));
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
verifyNoInteractions(contentNodeMapper);
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
}
@Test
void shouldRejectPlacementWhenCasLoses() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 202L)).thenReturn(availableNode());
ServiceException ex = assertThrows(ServiceException.class,
() -> service.place(101L, new QuestionPlacementCommand(202L, 1)));
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
verify(questionMapper).updatePlacement(10L, 101L, 202L, 1);
}
@Test
void shouldRejectPlacementOnCurrentNodeWithoutVersionChange() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
ServiceException ex = assertThrows(ServiceException.class,
() -> service.place(101L, new QuestionPlacementCommand(201L, 1)));
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
}
@Test
void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
@@ -141,7 +233,7 @@ class TenantQuestionLifecycleServiceImplTest {
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());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
}
@Test
@@ -154,7 +246,34 @@ class TenantQuestionLifecycleServiceImplTest {
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());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
verifyNoInteractions(auditMapper);
}
@Test
void shouldRejectPublishingUnplacedDraft() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
draft.setPlacementVersion(0);
draft.setNodeId(null);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_PLACEMENT_REQUIRED.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
verifyNoInteractions(contentNodeMapper, auditMapper);
}
@Test
void shouldRejectPublishingWhenPlacementTargetBecameUnavailable() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
verifyNoInteractions(auditMapper);
}
@@ -176,7 +295,7 @@ class TenantQuestionLifecycleServiceImplTest {
});
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
verifyNoInteractions(auditMapper);
}
@@ -206,8 +325,21 @@ class TenantQuestionLifecycleServiceImplTest {
question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," +
"{\"label\":\"B\",\"content\":\"5\",\"order\":2}]");
question.setCorrectAnswer("A");
question.setNodeId(201L);
question.setPlacementVersion(1);
question.setStatus("DRAFT");
question.setIsPublished(false);
return question;
}
private ContentNodeDO availableNode() {
ContentNodeDO node = new ContentNodeDO();
node.setId(201L);
node.setTenantId(10L);
node.setScope("TENANT_OWNED");
node.setIsActive(true);
node.setIsHidden(false);
node.setIsSelectable(true);
return node;
}
}

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", "4080");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
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");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
@@ -264,7 +264,7 @@ class EducationFlywayMigrationIntegrationTest {
@Test
void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException {
String schema = createSchema("question_lifecycle");
configureFlyway(schema, false).load().migrate();
configureFlyway(schema, false).target("4080").load().migrate();
assertThat(queryStrings(schema, """
SELECT column_default
@@ -386,6 +386,85 @@ class EducationFlywayMigrationIntegrationTest {
.hasMessageContaining("question version ownership must equal question ownership");
}
@Test
void shouldCreateQuestionPlacementSchemaAndFreezePublishedPlacement() throws SQLException {
String schema = createSchema("question_placement");
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT column_default || ':' || is_nullable
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_question'
AND column_name = 'placement_version'
"""))
.containsExactly("0:NO");
execute(schema, """
INSERT INTO education_content_entry
(id, tenant_id, scope, entry_key, name, entry_type)
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type)
VALUES (200, 10, 'TENANT_OWNED', 100, 'Algebra', 'category'),
(201, 10, 'TENANT_OWNED', 100, 'Geometry', 'category');
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer)
VALUES (300, 10, 'TENANT_OWNED', 'Placed draft', 'text', '[]', 'answer');
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
VALUES (10, 'TENANT_OWNED', 300, 1, 'Placed draft', 'text', '[]', 'answer');
INSERT INTO education_content_entry
(id, tenant_id, scope, entry_key, name, entry_type)
VALUES (110, 0, 'PUBLIC', 'public-questions', 'Public Questions', 'question');
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type)
VALUES (210, 0, 'PUBLIC', 110, 'Public Algebra', 'category'),
(211, 0, 'PUBLIC', 110, 'Public Geometry', 'category');
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, node_id)
VALUES (310, 0, 'PUBLIC', 'Public draft', 'text', '[]', 'answer', 210);
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
VALUES (0, 'PUBLIC', 310, 1, 'Public draft', 'text', '[]', 'answer');
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET node_id = 211, placement_version = 1
WHERE id = 310;
"""))
.hasMessageContaining("PUBLIC question placement is not managed by tenant authoring");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question SET node_id = 200 WHERE id = 300;
"""))
.hasMessageContaining("question placement version must advance exactly once");
execute(schema, """
UPDATE education_question
SET node_id = 200, placement_version = 1
WHERE id = 300;
DO $publish$
BEGIN
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 300;
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 300, 1, 7, 'DRAFT', 'PUBLISHED');
END
$publish$;
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET node_id = 201, placement_version = 2
WHERE id = 300;
"""))
.hasMessageContaining("published or archived question placement is immutable");
}
@Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history");
@@ -490,14 +569,15 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema, """
SELECT permission
FROM system_menu
WHERE id BETWEEN 6801 AND 6804
WHERE id BETWEEN 6801 AND 6805
ORDER BY id
"""))
.containsExactly(
"education:capability",
"education:question:author",
"education:question:publish",
"education:question:archive");
"education:question:archive",
"education:question:classify");
}
@Test
@@ -516,6 +596,23 @@ class EducationFlywayMigrationIntegrationTest {
.hasMessageContaining("Education System RBAC seed IDs conflict");
}
@Test
void shouldFailClosedWhenQuestionPlacementPermissionSeedConflicts() throws SQLException {
String schema = createSchema("question_placement_permission_conflict");
configureFlyway(schema, false).target("4070").load().migrate();
createSystemMenuFixture(schema);
configureFlyway(schema, false).target("4080").load().migrate();
execute(schema, """
INSERT INTO system_menu
(id, name, permission, type, sort, parent_id, status, deleted)
VALUES (6805, 'Conflicting placement permission', 'education:question:other',
3, 5, 6800, 0, 0);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("Education Question Placement RBAC seed ID conflicts");
}
@Test
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
String schema = createSchema("catalog_triggers");
@@ -606,6 +703,7 @@ class EducationFlywayMigrationIntegrationTest {
'education_require_question_lifecycle_audit',
'education_check_question_version_ownership',
'education_validate_question_lifecycle_audit_insert',
'education_enforce_question_placement_mutation',
'education_question_answer_key_is_valid')
AND grantee = 'PUBLIC'
AND privilege_type = 'EXECUTE'

View File

@@ -12,5 +12,7 @@ TRUNCATE TABLE
education_practice_report_detail,
education_practice_report,
education_practice_question,
education_practice_session
education_practice_session,
education_content_node,
education_content_entry
RESTART IDENTITY CASCADE;