fix(education): close collection integrity races

This commit is contained in:
2026-07-31 05:33:26 +08:00
parent 9a4d72bba4
commit e272b8ec5f
8 changed files with 131 additions and 19 deletions

View File

@@ -12,11 +12,14 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
@Select("""
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.id = #{collectionId} AND 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'))
""")
QuestionCollectionDO selectAvailableForStudent(@Param("tenantId") Long tenantId, @Param("collectionId") Long collectionId);
@@ -24,11 +27,14 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
<script>
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'))
<if test="entryId != null">AND collection.entry_id = #{entryId}</if>
<if test="nodeId != null">AND collection.node_id = #{nodeId}</if>
<if test="collectionType != null and collectionType != ''">AND collection.collection_type = #{collectionType}</if>
@@ -53,14 +59,10 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
.eq(QuestionCollectionDO::getAuthoringVersion, expectedVersion)
.set(QuestionCollectionDO::getAuthoringVersion, expectedVersion + 1));
}
default int advanceMembershipCas(Long tenantId, Long id, int expectedVersion, int count) {
return update(null, new LambdaUpdateWrapper<QuestionCollectionDO>().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<QuestionCollectionDO>().eq(QuestionCollectionDO::getId, id)
.eq(QuestionCollectionDO::getTenantId, tenantId).eq(QuestionCollectionDO::getScope, "TENANT_OWNED")

View File

@@ -11,6 +11,7 @@ public interface QuestionCollectionQuestionMapper extends BaseMapperX<QuestionCo
SELECT membership.* FROM education_question_collection_question membership
JOIN education_question_collection collection ON collection.id = membership.collection_id
JOIN education_content_node node ON node.id = collection.node_id
JOIN education_content_entry entry ON entry.id = node.entry_id
WHERE membership.collection_id = #{collectionId} AND membership.deleted = false
AND collection.deleted = false AND collection.publication_status = 'ACTIVE'
AND collection.is_active = true AND collection.is_hidden = false
@@ -18,6 +19,8 @@ public interface QuestionCollectionQuestionMapper extends BaseMapperX<QuestionCo
AND ((membership.tenant_id = #{tenantId} AND membership.scope = 'TENANT_OWNED') OR (membership.tenant_id = 0 AND membership.scope = 'PUBLIC'))
AND ((collection.tenant_id = #{tenantId} AND collection.scope = 'TENANT_OWNED') OR (collection.tenant_id = 0 AND collection.scope = 'PUBLIC'))
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'))
ORDER BY membership.sort_order, membership.id
""")
List<QuestionCollectionQuestionDO> selectAvailableByCollectionId(@Param("tenantId") Long tenantId,

View File

@@ -123,6 +123,15 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
""")
long countTenantOwnedPublishedByIds(@Param("tenantId") Long tenantId, @Param("ids") List<Long> 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));
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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<Throwable> activateError = new java.util.concurrent.atomic.AtomicReference<>();
java.util.concurrent.atomic.AtomicReference<Throwable> 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<Throwable> 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);

View File

@@ -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')"))