diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java index e49222c7..d3ebd544 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java @@ -12,11 +12,14 @@ public interface QuestionCollectionMapper extends BaseMapperX SELECT collection.* FROM education_question_collection collection JOIN education_content_node node ON node.id = collection.node_id + JOIN education_content_entry entry ON entry.id = node.entry_id WHERE collection.deleted = false AND collection.publication_status = 'ACTIVE' AND collection.is_active = true AND collection.is_hidden = false AND ((collection.tenant_id = #{tenantId} AND collection.scope = 'TENANT_OWNED') OR (collection.tenant_id = 0 AND collection.scope = 'PUBLIC')) AND node.deleted = false AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED') OR (node.tenant_id = 0 AND node.scope = 'PUBLIC')) + AND entry.deleted = false AND entry.is_active = true AND entry.is_hidden = false + AND ((entry.tenant_id = #{tenantId} AND entry.scope = 'TENANT_OWNED') OR (entry.tenant_id = 0 AND entry.scope = 'PUBLIC')) AND collection.entry_id = #{entryId} AND collection.node_id = #{nodeId} AND collection.collection_type = #{collectionType} @@ -53,14 +59,10 @@ public interface QuestionCollectionMapper extends BaseMapperX().eq(QuestionCollectionDO::getId, id) - .eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED") - .eq(QuestionCollectionDO::getDeleted, false).eq(QuestionCollectionDO::getPublicationStatus, "DRAFT") - .eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion) - .set(QuestionCollectionDO::getQuestionCount, count) - .set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1)); - } + @Select("SELECT education_claim_question_collection_membership(current_schema(), #{tenantId}, #{id}, #{expectedVersion}, #{count})") + int advanceMembershipCas(@Param("tenantId") Long tenantId, @Param("id") Long id, + @Param("expectedVersion") int expectedVersion, @Param("count") int count); + default int updateLifecycleCas(Long tenantId, Long id, String from, String to, boolean active, int expectedVersion) { return update(null, new LambdaUpdateWrapper().eq(QuestionCollectionDO::getId, id) .eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionQuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionQuestionMapper.java index 43959142..e41f80aa 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionQuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionQuestionMapper.java @@ -11,6 +11,7 @@ public interface QuestionCollectionQuestionMapper extends BaseMapperX selectAvailableByCollectionId(@Param("tenantId") Long tenantId, diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java index 9fe21ab3..d4917aea 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java @@ -123,6 +123,15 @@ public interface QuestionMapper extends BaseMapperX { """) long countTenantOwnedPublishedByIds(@Param("tenantId") Long tenantId, @Param("ids") List ids); + @Select(""" + SELECT count(*) FROM education_question_collection_question membership + JOIN education_question_collection collection ON collection.id = membership.collection_id + WHERE membership.question_id = #{questionId} AND membership.tenant_id = #{tenantId} + AND membership.scope = 'TENANT_OWNED' AND membership.deleted = false + AND collection.publication_status = 'ACTIVE' AND collection.deleted = false + """) + long countActiveCollectionMembership(@Param("tenantId") Long tenantId, @Param("questionId") Long questionId); + default QuestionDO selectPublishedById(Long tenantId, Long id) { return selectOne(visible(tenantId).eq(QuestionDO::getId, id)); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index e0545763..ac8c8a14 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -107,6 +107,7 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_PLACEMENT_TARGET_UNAVAILABLE = new ErrorCode(1_005_003_074, "题目归类目标不存在或不可用"); ErrorCode QUESTION_PLACEMENT_CONFLICT = new ErrorCode(1_005_003_075, "题目归类已变化,请刷新后重试"); ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布"); + ErrorCode QUESTION_ACTIVE_COLLECTION_CONFLICT = new ErrorCode(1_005_003_077, "题目仍属于已发布题集,无法归档"); // ========== 内容节点创作 1-005-002-020 ~ 1-005-002-029 ========== ErrorCode CONTENT_NODE_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_020, diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java index 2a7d22e3..50e2c4be 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java @@ -156,6 +156,9 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc } if (PUBLISHED.equals(toStatus)) { validatePublishable(question); + } else if (ARCHIVED.equals(toStatus) + && TenantUtils.executeIgnore(() -> questionMapper.countActiveCollectionMembership(tenantId, questionId)) > 0) { + throw exception(QUESTION_ACTIVE_COLLECTION_CONFLICT); } if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published, question.getPlacementVersion()) != 1) { diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql index efefd162..9158dc86 100644 --- a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql +++ b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql @@ -44,6 +44,7 @@ REVOKE ALL ON TABLE education_question_collection_membership_token FROM PUBLIC; CREATE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE member RECORD; BEGIN IF TG_OP='INSERT' THEN IF NEW.deleted THEN RAISE EXCEPTION 'collection cannot start deleted' USING ERRCODE='23514'; END IF; @@ -64,19 +65,34 @@ BEGIN NEW.question_count IS DISTINCT FROM OLD.question_count OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR NEW.access_rules IS DISTINCT FROM OLD.access_rules OR NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN RAISE EXCEPTION 'active collection content is immutable' USING ERRCODE='23514'; END IF; - IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status AND OLD.publication_status='DRAFT' THEN - EXECUTE format('INSERT INTO %I.education_question_collection_membership_token VALUES ($1,$2,$3,$4)',TG_TABLE_SCHEMA) - USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version; - 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 collection lifecycle transition' USING ERRCODE='23514'; END IF; + IF NEW.publication_status='ACTIVE' THEN + FOR member IN EXECUTE format('SELECT question.tenant_id,question.scope,question.status,question.is_published,question.deleted FROM %I.education_question_collection_question membership JOIN %I.education_question question ON question.id=membership.question_id WHERE membership.collection_id=$1 AND NOT membership.deleted FOR SHARE OF question',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) USING NEW.id LOOP + IF member.tenant_id IS DISTINCT FROM NEW.tenant_id OR member.scope<>'TENANT_OWNED' OR member.status<>'PUBLISHED' OR NOT member.is_published OR member.deleted THEN + RAISE EXCEPTION 'active collection requires published tenant questions' USING ERRCODE='23514'; + END IF; + END LOOP; + END IF; EXECUTE format('INSERT INTO %I.education_question_collection_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; END IF; RETURN NEW; END $$; +CREATE FUNCTION education_claim_question_collection_membership(p_schema TEXT, p_tenant_id BIGINT, p_collection_id BIGINT, p_expected_version INTEGER, p_question_count INTEGER) RETURNS INTEGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE claimed_version INTEGER; +BEGIN + EXECUTE format('UPDATE %I.education_question_collection SET question_count=$1,authoring_version=$2 WHERE id=$3 AND tenant_id=$4 AND scope=''TENANT_OWNED'' AND publication_status=''DRAFT'' AND deleted=false AND authoring_version=$5 RETURNING authoring_version',p_schema) + INTO claimed_version USING p_question_count,p_expected_version+1,p_collection_id,p_tenant_id,p_expected_version; + IF claimed_version IS NULL THEN RETURN 0; END IF; + EXECUTE format('INSERT INTO %I.education_question_collection_membership_token VALUES ($1,$2,$3,$4)',p_schema) + USING pg_current_xact_id()::text::BIGINT,p_tenant_id,p_collection_id,claimed_version; + RETURN 1; +END $$; + CREATE FUNCTION education_enforce_collection_membership_mutation() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; q_deleted BOOLEAN; token_count INTEGER; @@ -97,6 +113,18 @@ BEGIN RETURN NEW; END $$; +CREATE FUNCTION education_block_active_collection_question_archive() RETURNS TRIGGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE active_collection BIGINT; +BEGIN + IF OLD.status='PUBLISHED' AND NEW.status='ARCHIVED' THEN + EXECUTE format('SELECT collection.id FROM %I.education_question_collection_question membership JOIN %I.education_question_collection collection ON collection.id=membership.collection_id WHERE membership.question_id=$1 AND NOT membership.deleted AND collection.publication_status=''ACTIVE'' AND NOT collection.deleted LIMIT 1 FOR SHARE OF collection',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA) + INTO active_collection USING NEW.id; + IF active_collection IS NOT NULL THEN RAISE EXCEPTION 'question belongs to active collection' USING ERRCODE='23514'; END IF; + END IF; + RETURN NEW; +END $$; + CREATE FUNCTION education_validate_question_collection_audit() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ DECLARE c_tenant BIGINT; c_version INTEGER; c_status VARCHAR(16); consumed INTEGER; @@ -114,12 +142,15 @@ END $$; CREATE FUNCTION education_require_question_collection_membership_count() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ -DECLARE actual_count INTEGER; consumed INTEGER; +DECLARE actual_count INTEGER; distinct_order_count INTEGER; minimum_order INTEGER; maximum_order INTEGER; consumed INTEGER; BEGIN IF OLD.publication_status IS DISTINCT FROM 'DRAFT' OR NEW.publication_status IS DISTINCT FROM 'DRAFT' THEN RETURN NULL; END IF; - EXECUTE format('SELECT count(*) FROM %I.education_question_collection_question WHERE tenant_id=$1 AND collection_id=$2 AND deleted=false',TG_TABLE_SCHEMA) - INTO actual_count USING NEW.tenant_id,NEW.id; - IF actual_count IS DISTINCT FROM NEW.question_count THEN RAISE EXCEPTION 'collection question_count must equal physical membership' USING ERRCODE='23514'; END IF; + EXECUTE format('SELECT count(*),count(DISTINCT sort_order),min(sort_order),max(sort_order) FROM %I.education_question_collection_question WHERE tenant_id=$1 AND collection_id=$2 AND deleted=false',TG_TABLE_SCHEMA) + INTO actual_count,distinct_order_count,minimum_order,maximum_order USING NEW.tenant_id,NEW.id; + IF actual_count IS DISTINCT FROM NEW.question_count OR distinct_order_count IS DISTINCT FROM actual_count + OR (actual_count>0 AND (minimum_order<>0 OR maximum_order<>actual_count-1)) THEN + RAISE EXCEPTION 'collection question_count and order must equal physical membership' USING ERRCODE='23514'; + END IF; EXECUTE format('DELETE FROM %I.education_question_collection_membership_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version; GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'collection membership transaction token required' USING ERRCODE='23514'; END IF; @@ -135,13 +166,16 @@ LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEG CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN RAISE EXCEPTION 'collection audit is append-only' USING ERRCODE='23514'; END $$; REVOKE ALL ON FUNCTION education_enforce_question_collection_authoring() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION education_claim_question_collection_membership(TEXT,BIGINT,BIGINT,INTEGER,INTEGER) TO PUBLIC; REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_block_active_collection_question_archive() FROM PUBLIC; REVOKE ALL ON FUNCTION education_validate_question_collection_audit() FROM PUBLIC; REVOKE ALL ON FUNCTION education_require_question_collection_membership_count() FROM PUBLIC; REVOKE ALL ON FUNCTION education_require_question_collection_audit() FROM PUBLIC; REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() FROM PUBLIC; CREATE TRIGGER trg_education_question_collection_authoring BEFORE INSERT OR UPDATE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_enforce_question_collection_authoring(); CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPDATE OR DELETE ON education_question_collection_question FOR EACH ROW EXECUTE FUNCTION education_enforce_collection_membership_mutation(); +CREATE TRIGGER trg_education_question_active_collection_archive BEFORE UPDATE OF status ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive(); CREATE TRIGGER trg_education_question_collection_audit_validate BEFORE INSERT ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_question_collection_audit(); CREATE TRIGGER trg_education_question_collection_audit_immutable BEFORE UPDATE OR DELETE ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_audit_mutation(); CREATE CONSTRAINT TRIGGER trg_education_question_collection_membership_count AFTER UPDATE OF question_count ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_membership_count(); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java index 965721fb..333e54a0 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java @@ -97,6 +97,59 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode()); } + @Test void activeCollectionBlocksQuestionArchiveUntilCollectionArchive() { + Long id = service.createDraft(command(null)); + service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); + service.activate(id, 1, 7L); + ServiceException conflict = assertThrows(ServiceException.class, + () -> questionService.archive(publishedQuestionIds.get(0), 7L)); + assertEquals(QUESTION_ACTIVE_COLLECTION_CONFLICT.getCode(), conflict.getCode()); + assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0))); + service.archive(id, 2, 7L); + questionService.archive(publishedQuestionIds.get(0), 7L); + assertNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0))); + } + + @Test void activationAndQuestionArchiveRaceHasOneWinner() throws Exception { + Long collectionId = service.createDraft(command(null)); + Long questionId = publishedQuestionIds.get(0); + service.replaceMembership(collectionId, List.of(questionId), 0); + java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(2); + java.util.concurrent.CountDownLatch go = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicInteger successes = new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicReference activateError = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference archiveError = new java.util.concurrent.atomic.AtomicReference<>(); + Thread activate = new Thread(() -> raceCommand(ready, go, successes, activateError, () -> service.activate(collectionId, 1, 7L))); + Thread archive = new Thread(() -> raceCommand(ready, go, successes, archiveError, () -> questionService.archive(questionId, 7L))); + activate.start(); archive.start(); assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS)); go.countDown(); + activate.join(10000); archive.join(10000); + assertEquals(1, successes.get()); + assertFalse(activate.isAlive()); assertFalse(archive.isAlive()); + assertTrue((collectionMapper.selectAvailableForStudent(10L, collectionId) != null) + ^ (questionMapper.selectPublishedById(10L, questionId) == null)); + } + + private void raceCommand(java.util.concurrent.CountDownLatch ready, java.util.concurrent.CountDownLatch go, + java.util.concurrent.atomic.AtomicInteger successes, + java.util.concurrent.atomic.AtomicReference error, Runnable command) { + TenantContextHolder.setTenantId(10L); ready.countDown(); + try { assertTrue(go.await(5, java.util.concurrent.TimeUnit.SECONDS)); command.run(); successes.incrementAndGet(); } + catch (Throwable throwable) { error.set(throwable); } + finally { TenantContextHolder.clear(); } + } + + @Test void unavailableEntryClosesCollectionRoutes() { + Long id = service.createDraft(command(null)); + service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); + service.activate(id, 1, 7L); + assertNotNull(collectionMapper.selectAvailableForStudent(10L, id)); + ContentEntryDO update = new ContentEntryDO(); update.setId(100L); update.setIsHidden(true); + entryMapper.updateById(update); + assertNull(collectionMapper.selectAvailableForStudent(10L, id)); + assertTrue(membershipMapper.selectByCollectionId(10L, id).isEmpty()); + assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0))); + } + @Test void staleAndActiveMutationAreRejected() { Long id = service.createDraft(command(null)); service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0); service.activate(id, 1, 7L); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 3965f76f..32f8dd7e 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -693,7 +693,13 @@ class EducationFlywayMigrationIntegrationTest { """); assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET publication_status='ACTIVE',is_active=true,is_hidden=false,authoring_version=1 WHERE id=300")) .hasMessageContaining("collection lifecycle audit required"); - assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET collection_type='DYNAMIC',authoring_version=1 WHERE id=300")) + execute(schema, "UPDATE education_question_collection SET name='Metadata',authoring_version=1 WHERE id=300"); + assertThat(queryLong(schema, "SELECT count(*) FROM education_question_collection_membership_token")).isZero(); + assertThatThrownBy(() -> execute(schema, """ + INSERT INTO education_question_collection_question(tenant_id,scope,collection_id,question_id,sort_order) + VALUES(10,'TENANT_OWNED',300,999,0); + """)).hasMessageContaining("collection membership mutation requires transaction token"); + assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET collection_type='DYNAMIC',authoring_version=2 WHERE id=300")) .hasMessageContaining("tenant collection type is immutable MANUAL"); String runtimeRole = "edu_collection_runtime_" + UUID.randomUUID().toString().replace("-", ""); @@ -703,11 +709,12 @@ class EducationFlywayMigrationIntegrationTest { execute(schema, "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA " + schema + " TO " + runtimeRole); execute(schema, "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA " + schema + " TO " + runtimeRole); execute(schema, "REVOKE ALL ON " + schema + ".education_question_collection_lifecycle_transition_token, " + schema + ".education_question_collection_membership_token FROM " + runtimeRole); + execute(schema, "GRANT EXECUTE ON FUNCTION " + schema + ".education_claim_question_collection_membership(TEXT,BIGINT,BIGINT,INTEGER,INTEGER) TO " + runtimeRole); try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), runtimeRole, "runtime_test"); var statement = connection.createStatement()) { statement.execute(""" DO $activate$ BEGIN - UPDATE education_question_collection SET publication_status='ACTIVE',is_active=true,is_hidden=false,authoring_version=1 WHERE id=300; - INSERT INTO education_question_collection_lifecycle_audit(tenant_id,collection_id,authoring_version,actor_id,from_status,to_status) VALUES(10,300,1,7,'DRAFT','ACTIVE'); + UPDATE education_question_collection SET publication_status='ACTIVE',is_active=true,is_hidden=false,authoring_version=2 WHERE id=300; + INSERT INTO education_question_collection_lifecycle_audit(tenant_id,collection_id,authoring_version,actor_id,from_status,to_status) VALUES(10,300,2,7,'DRAFT','ACTIVE'); END $activate$; """); assertThatThrownBy(() -> statement.execute("INSERT INTO education_question_collection_lifecycle_transition_token VALUES(1,10,300,2,'ACTIVE','ARCHIVED')"))