fix(education): close collection integrity races
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')"))
|
||||
|
||||
Reference in New Issue
Block a user