test(ldd): конкурентность, нагрузка и release gate

This commit is contained in:
2026-10-28 00:40:08 +03:00
parent 05f628a52b
commit 85d34e78cb
7 changed files with 395 additions and 1 deletions

View File

@@ -0,0 +1,113 @@
package ru.ysu.lms.identity.promotion
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.GroupService
import java.util.UUID
import kotlin.random.Random
/**
* Model-based проверка транзакционного apply/rollback на сгенерированных
* когортах: после apply состояние соответствует плану, после rollback —
* ровно before-state.
*/
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
@Transactional
class PromotionApplyRollbackPropertyTest {
companion object {
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var promotionService: PromotionService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var students: StudentRepository
@Test
fun `generated cohorts apply to plan and rollback to before-state`() {
val random = Random(20261029)
repeat(10) { index ->
val degree = if (random.nextBoolean()) "БО" else "МО"
val limit = if (degree == "БО") 4 else 2
val course = random.nextInt(1, limit + 1)
val year = 2040 + index
val group =
groupService.create(
CreateGroupRequest(
stream = "PR",
course = course.toShort(),
number = (index + 1).toShort(),
degree = degree,
enrollmentYear = (year - 1).toShort(),
),
)
val beforeName = group.name
val student =
groupService.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(
studentNo = "PR-$index-${UUID.randomUUID().toString().take(8)}",
lastName = "Тестов",
),
null,
)
val studentId = UUID.fromString(student.id)
val plan = promotionService.dryRun(year, null)
val planItem = plan.items.first { it.entityId == group.id }
val expectedAction = if (course < limit) PromotionAction.PROMOTE else PromotionAction.GRADUATE
assertEquals(expectedAction.name, planItem.action)
promotionService.apply(UUID.fromString(plan.id), null)
val afterApply = groups.findById(UUID.fromString(group.id)).orElseThrow()
if (course < limit) {
assertEquals((course + 1).toShort(), afterApply.course)
assertTrue(afterApply.name != beforeName)
} else {
assertEquals(GroupStatus.GRADUATED, afterApply.status)
}
promotionService.rollback(UUID.fromString(plan.id), null)
val afterRollback = groups.findById(UUID.fromString(group.id)).orElseThrow()
assertEquals(beforeName, afterRollback.name)
assertEquals(course.toShort(), afterRollback.course)
assertEquals(GroupStatus.ACTIVE, afterRollback.status)
assertEquals(
students.findById(studentId).orElseThrow().status,
ru.ysu.lms.identity.domain.StudentStatus.ACTIVE,
)
}
}
}

View File

@@ -0,0 +1,140 @@
package ru.ysu.lms.identity.promotion
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.PromotionMode
import ru.ysu.lms.identity.domain.PromotionRunStatus
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.PromotionRunRepository
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.GroupService
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* Конкурентный apply/rollback одного запуска. Класс не @Transactional:
* каждый вызов сервиса должен получить собственную транзакцию и advisory lock.
*/
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
class PromotionConcurrencyIntegrationTest {
companion object {
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var promotionService: PromotionService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var history: MembershipHistoryRepository
@Autowired lateinit var runs: PromotionRunRepository
@Test
fun `concurrent apply of one run is idempotent and writes history once`() {
val fixture = fixture(number = 1, studentNo = "CONC-APPLY", year = 2030)
val plan = promotionService.dryRun(2030, null)
val runId = UUID.fromString(plan.id)
val results = runConcurrently { promotionService.apply(runId, null) }
assertTrue(results.all { it.isSuccess }, "оба apply должны завершиться идемпотентно")
assertEquals(1, runs.findAll().count { it.year == 2030 && it.mode == PromotionMode.APPLY })
assertEquals(1, history.findByStudentIdOrderByAtDesc(fixture.studentId).count { it.action == MembershipAction.PROMOTE })
assertEquals(2.toShort(), groups.findById(fixture.groupId).orElseThrow().course)
}
@Test
fun `concurrent rollback allows exactly one rollback and restores once`() {
val fixture = fixture(number = 2, studentNo = "CONC-ROLLBACK", year = 2031)
val plan = promotionService.dryRun(2031, null)
val runId = UUID.fromString(plan.id)
promotionService.apply(runId, null)
val results = runConcurrently { promotionService.rollback(runId, null) }
assertEquals(1, results.count { it.isSuccess })
assertEquals(1, results.count { it.exceptionOrNull() is ConflictException })
assertEquals(PromotionRunStatus.ROLLED_BACK, runs.findById(runId).orElseThrow().status)
assertEquals(1.toShort(), groups.findById(fixture.groupId).orElseThrow().course)
assertEquals(GroupStatus.ACTIVE, groups.findById(fixture.groupId).orElseThrow().status)
}
private fun runConcurrently(block: () -> Unit): List<Result<Unit>> {
val start = CountDownLatch(1)
val pool = Executors.newFixedThreadPool(2)
try {
val futures =
(1..2).map {
pool.submit<Result<Unit>> {
start.await()
runCatching { block() }
}
}
start.countDown()
return futures.map { it.get(30, TimeUnit.SECONDS) }
} finally {
pool.shutdownNow()
}
}
private fun fixture(
number: Short,
studentNo: String,
year: Int,
): Fixture {
val group =
groupService.create(
CreateGroupRequest(
stream = "ПИ",
course = 1,
number = number,
degree = "БО",
enrollmentYear = (year - 1).toShort(),
),
)
val student =
groupService.createStudent(
UUID.fromString(group.id),
CreateStudentRequest(studentNo = studentNo, lastName = "Тестов"),
null,
)
return Fixture(UUID.fromString(group.id), UUID.fromString(student.id))
}
private data class Fixture(
val groupId: UUID,
val studentId: UUID,
)
}