test(education): verify category blueprint publication

This commit is contained in:
2026-07-31 12:56:15 +08:00
parent b8b06e0292
commit be2c22e440
9 changed files with 121 additions and 42 deletions

View File

@@ -31,12 +31,12 @@ ALTER TABLE education_practice_blueprint
question_limit IS NULL OR question_limit BETWEEN min_questions AND max_questions),
ADD CONSTRAINT ck_education_practice_blueprint_duration CHECK (duration_minutes IS NULL OR duration_minutes > 0);
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_node
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_live_node
ON education_practice_blueprint(tenant_id,node_id)
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status='ACTIVE';
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_collection
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status IN ('DRAFT','ACTIVE');
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_live_collection
ON education_practice_blueprint(tenant_id,collection_id)
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='COLLECTION' AND publication_status='ACTIVE';
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='COLLECTION' AND publication_status IN ('DRAFT','ACTIVE');
CREATE TABLE education_category_lifecycle_audit (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -85,7 +85,7 @@ BEGIN
IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'tenant category cannot be logically deleted' USING ERRCODE='23514'; END IF;
IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived category is immutable' USING ERRCODE='23514'; END IF;
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'category version must advance once' USING ERRCODE='23514'; END IF;
IF OLD.publication_status<>'DRAFT' AND (NEW.subject_id IS DISTINCT FROM OLD.subject_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.sort_order IS DISTINCT FROM OLD.sort_order) THEN RAISE EXCEPTION 'active category is immutable' USING ERRCODE='23514'; END IF;
IF OLD.publication_status<>'DRAFT' AND (NEW.subject_id IS DISTINCT FROM OLD.subject_id OR NEW.legacy_node_id IS DISTINCT FROM OLD.legacy_node_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.sort_order IS DISTINCT FROM OLD.sort_order) THEN RAISE EXCEPTION 'active category is immutable' USING ERRCODE='23514'; END IF;
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN RAISE EXCEPTION 'invalid category lifecycle' USING ERRCODE='23514'; END IF;
EXECUTE format('INSERT INTO %I.education_category_lifecycle_transition_token VALUES($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;

View File

@@ -68,9 +68,10 @@ class PracticeBlueprintAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIn
@Test void activeBlueprintContentAndAuditAreDatabaseImmutable() throws Exception {
Long id = service.createDraft(command(null)); service.activate(id, 0, 7L);
assertThrows(Exception.class, () -> blueprintMapper.updateDraftCas(10L, id,
blueprintMapper.selectTenantOwnedById(10L, id), 1));
PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne();
PracticeBlueprintDO active = blueprintMapper.selectTenantOwnedById(10L, id);
active.setSuggestedCount(1);
assertEquals(0, blueprintMapper.updateDraftCas(10L, id, active, 1));
PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne(PracticeBlueprintLifecycleAuditDO::getBlueprintId, id);
audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit));
}

View File

@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.education.service.category.authoring;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.*;
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Import({CategoryAuthoringServiceImpl.class, EducationProperties.class,
cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class})
@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"})
class CategoryAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource EducationProperties properties;
@Resource CategoryAuthoringService service;
@Resource CategoryMapper categoryMapper;
@Resource CategoryLifecycleAuditMapper auditMapper;
@Resource SubjectMapper subjectMapper;
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
@BeforeEach void setUp() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ); TenantContextHolder.setTenantId(10L);
SubjectDO subject = new SubjectDO(); subject.setId(100L); subject.setTenantId(10L); subject.setScope("TENANT_OWNED");
subject.setName("Subject"); subject.setIsActive(true); subjectMapper.insert(subject);
}
@AfterEach void clear() { TenantContextHolder.clear(); }
@Test void draftActiveArchiveControlsStudentVisibilityAndAudit() {
Long id = service.createDraft(command(null));
assertTrue(catalogProvider.listCategories("100", null).isEmpty());
assertEquals(1, service.activate(id, 0, 7L));
assertEquals(1, catalogProvider.listCategories("100", null).size());
assertEquals(2, service.archive(id, 1, 7L));
assertTrue(catalogProvider.listCategories("100", null).isEmpty());
assertEquals(2L, auditMapper.selectCount());
}
@Test void unsupportedCrossTenantAndStaleFailClosed() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null)));
assertEquals(CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode());
properties.setCatalogMode(CatalogProviderMode.JAVA_READ); Long id = service.createDraft(command(null));
TenantContextHolder.setTenantId(20L);
ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L));
assertEquals(CATEGORY_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
TenantContextHolder.setTenantId(10L); service.activate(id, 0, 7L);
ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 0, 7L));
assertEquals(CATEGORY_AUTHORING_CONFLICT.getCode(), stale.getCode());
}
@Test void lifecycleAuditIsAppendOnly() {
Long id = service.createDraft(command(null)); service.activate(id, 0, 7L);
CategoryLifecycleAuditDO audit = auditMapper.selectOne(CategoryLifecycleAuditDO::getCategoryId, id);
audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit));
}
private CategoryAuthoringCommand command(Integer version) {
return new CategoryAuthoringCommand(100L, null, "Category", 0, version);
}
}

View File

@@ -8,6 +8,10 @@ TRUNCATE TABLE
education_entitlement_event,
education_entitlement,
education_resource_product_binding,
education_practice_blueprint_lifecycle_audit,
education_category_lifecycle_audit,
education_practice_blueprint,
education_category,
education_question_collection_lifecycle_audit,
education_content_node_lifecycle_audit,
education_question_lifecycle_transition_token,
@@ -25,5 +29,6 @@ TRUNCATE TABLE
education_practice_question,
education_practice_session,
education_content_node,
education_content_entry
education_content_entry,
education_subject
RESTART IDENTITY CASCADE;