feat(ldd): property-based, mutation и браузерный E2E

This commit is contained in:
2026-10-28 00:01:56 +03:00
parent 80ccf26b61
commit 905feb440b
18 changed files with 1432 additions and 2 deletions

View File

@@ -2,6 +2,58 @@ plugins {
id("lms.spring-boot")
}
/** PIT запускается отдельной задачей: Gradle-плагин 1.15 несовместим с Gradle 9.6. */
val pitest by configurations.creating
dependencies {
add("pitest", libs.pitestCommandLine)
}
tasks.register<JavaExec>("pitestCourseAccess") {
group = "verification"
description = "Mutation testing CourseAccessService (P0 access matrix)"
dependsOn(tasks.testClasses)
classpath = pitest + sourceSets.getByName("test").runtimeClasspath
mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport")
val reportDir =
layout.buildDirectory
.dir("reports/pitest-course-access")
.get()
.asFile
.absolutePath
val sourceDirs =
sourceSets.main
.get()
.kotlin.srcDirs
.joinToString(",") { it.absolutePath }
val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath
args(
"--reportDir",
reportDir,
"--targetClasses",
"ru.ysu.lms.academic.course.CourseAccessService",
"--targetTests",
"ru.ysu.lms.academic.course.CourseAccessServiceMutationTest",
"--sourceDirs",
sourceDirs,
"--classPath",
testClasspath,
"--mutators",
"STRONGER",
"--threads",
"1",
"--verbose",
"--outputFormats",
"XML,HTML",
"--timestampedReports",
"false",
"--timeoutConst",
"15000",
"--failWhenNoMutations",
"true",
)
}
dependencies {
implementation(platform(libs.springBootBom))
@@ -16,6 +68,7 @@ dependencies {
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
@@ -24,6 +77,9 @@ dependencies {
testImplementation(project(":libs:test-support"))
testImplementation(libs.springSecurityTest)
// PIT 1.15/1.20 JUnit5-плагин несовместим с JUnit Platform 6 (Spring Boot 4):
// mutation harness запускается через встроенный JUnit 4 plugin.
testImplementation("junit:junit:4.13.2")
}
springBoot {

View File

@@ -0,0 +1,257 @@
package ru.ysu.lms.academic.course
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import org.springframework.security.oauth2.jwt.Jwt
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.ForbiddenException
import java.util.Optional
import java.util.UUID
/**
* JUnit 4 harness для PIT: JUnit5-плагин PIT несовместим с JUnit Platform 6,
* а встроенный JUnit 4 runner — совместим. Обычный `./gradlew test` этот класс
* не запускает; он нужен только задаче `pitestCourseAccess`.
*/
class CourseAccessServiceMutationTest {
private val courses = mockk<CourseRepository>()
private val teachers = mockk<CourseTeacherRepository>()
private val groups = mockk<CourseGroupRepository>()
private val labs = mockk<LabRepository>()
private val identity = mockk<IdentityClient>()
private val courseService = mockk<CourseService>()
private val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
courseService,
)
@Test
fun `admin bypasses archived course`() {
val archived = course(Course.STATUS_ARCHIVED)
assertThatCode {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_ADMIN))
}.doesNotThrowAnyException()
}
@Test
fun `teacher assigned active course allowed`() {
val active = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.doesNotThrowAnyException()
}
@Test
fun `teacher foreign and archived courses denied`() {
val assigned = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val archived = course(Course.STATUS_ARCHIVED)
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = assigned, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(assigned, archived)
assertThatThrownBy {
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
assertThatThrownBy {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `teacher without active assignment denied`() {
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
assertThatThrownBy {
service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `student current active group allowed and foreign denied`() {
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_STUDENT))
}.doesNotThrowAnyException()
assertThatThrownBy {
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `student without group and archived course denied`() {
every { identity.currentGroupIds(TOKEN) } returns emptyList()
assertThatThrownBy {
service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
val archived = course(Course.STATUS_ARCHIVED)
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = archived, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(archived)
assertThatThrownBy {
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `lab without course is admin only`() {
val lab = Lab(number = 1, title = "Без курса")
every { labs.findById(lab.id) } returns Optional.of(lab)
assertThatCode {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_ADMIN))
}.doesNotThrowAnyException()
assertThatThrownBy {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `lab access follows course`() {
val active = course(Course.STATUS_ACTIVE)
val lab = Lab(number = 1, title = "Лабораторная", course = active)
val groupId = UUID.randomUUID()
every { labs.findById(lab.id) } returns Optional.of(lab)
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatCode {
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
}.doesNotThrowAnyException()
assertThat(service.accessibleCourseIds(jwt(CourseAccessService.ROLE_ADMIN))).isNull()
}
@Test
fun `my courses admin teacher and student are filtered`() {
val active = course(Course.STATUS_ACTIVE)
val archived = course(Course.STATUS_ARCHIVED)
every { courseService.list() } returns listOf(summary(active), summary(archived))
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_ADMIN)).map { it.id })
.containsExactly(active.id.toString(), archived.id.toString())
val teacherId = UUID.randomUUID()
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(
CourseTeacher(course = active, accountId = teacherId),
CourseTeacher(course = archived, accountId = teacherId),
)
every { courses.findAllById(any()) } returns listOf(active, archived)
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, teacherId)).map { it.id })
.containsExactly(active.id.toString())
val groupId = UUID.randomUUID()
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
listOf(CourseGroup(course = active, groupId = groupId))
every { courses.findAllById(any()) } returns listOf(active)
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_STUDENT)).map { it.id })
.containsExactly(active.id.toString())
}
@Test
fun `empty course list and unknown role are safe`() {
every { teachers.findByAccountIdAndRemovedAtIsNull(any()) } returns emptyList()
every { courseService.list() } returns emptyList()
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, UUID.randomUUID()))).isEmpty()
assertThat(service.accessibleCourseIds(jwt("UNKNOWN"))).isEmpty()
assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_ADMIN))).isTrue()
assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_TEACHER))).isFalse()
}
@Test
fun `invalid subject and foreign lab are denied`() {
val invalid =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject("not-a-uuid")
.claim("role", "TEACHER")
.build()
assertThatThrownBy { service.accessibleCourseIds(invalid) }
.isInstanceOf(ForbiddenException::class.java)
val active = course(Course.STATUS_ACTIVE)
val foreign = course(Course.STATUS_ACTIVE)
val teacherId = UUID.randomUUID()
val lab = Lab(number = 1, title = "Лабораторная", course = foreign)
every { labs.findById(lab.id) } returns Optional.of(lab)
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = active, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(active)
assertThatThrownBy { service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) }
.isInstanceOf(ForbiddenException::class.java)
}
@Test
fun `missing lab is not found`() {
every { labs.findById(any()) } returns Optional.empty()
assertThatThrownBy { service.requireLabAccess(UUID.randomUUID(), jwt(CourseAccessService.ROLE_ADMIN)) }
.isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java)
}
private fun course(status: String): Course = Course(title = "Курс", status = status)
private fun summary(course: Course): CourseSummary =
CourseSummary(
id = course.id.toString(),
courseNo = 1,
title = course.title,
ownerAccountId = course.ownerAccountId.toString(),
status = course.status,
teacherCount = 0,
groupCount = 0,
labCount = 0,
)
private fun jwt(
role: String,
subject: UUID = UUID.randomUUID(),
): Jwt =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject(subject.toString())
.claim("role", role)
.build()
private companion object {
const val TOKEN = "token"
}
}

View File

@@ -0,0 +1,153 @@
package ru.ysu.lms.academic.course
import io.mockk.every
import io.mockk.mockk
import net.jqwik.api.ForAll
import net.jqwik.api.Property
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.springframework.security.oauth2.jwt.Jwt
import ru.ysu.lms.academic.domain.Course
import ru.ysu.lms.academic.domain.CourseGroup
import ru.ysu.lms.academic.domain.CourseTeacher
import ru.ysu.lms.academic.identity.IdentityClient
import ru.ysu.lms.academic.repo.CourseGroupRepository
import ru.ysu.lms.academic.repo.CourseRepository
import ru.ysu.lms.academic.repo.CourseTeacherRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.common.ForbiddenException
import java.util.UUID
/**
* Property-based проверка матрицы доступа: не отдельные примеры, а
* утверждение «доступ есть ровно тогда, когда выполнены все условия».
* jqwik перебирает комбинации флагов; PIT дополнительно проверяет, что
* тест падает при мутации логики.
*/
class CourseAccessServicePropertyTest {
@Suppress("ktlint:standard:function-naming")
@Property(tries = 80)
fun `teacher access equals assigned and active course`(
@ForAll hasAssignment: Boolean,
@ForAll targetAssigned: Boolean,
@ForAll active: Boolean,
) {
val courses = mockk<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
val teacherId = UUID.randomUUID()
val assigned = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
val target = if (targetAssigned) assigned else course(Course.STATUS_ACTIVE)
if (hasAssignment) {
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
listOf(CourseTeacher(course = assigned, accountId = teacherId))
every { courses.findAllById(any()) } returns listOf(assigned)
} else {
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
}
val thrown =
catchThrowable {
service.requireCourseAccess(target.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
}
val expected = hasAssignment && targetAssigned && active
if (expected) {
assertThat(thrown).isNull()
} else {
assertThat(thrown).isInstanceOf(ForbiddenException::class.java)
}
}
@Suppress("ktlint:standard:function-naming")
@Property(tries = 80)
fun `student access equals current attached active course`(
@ForAll hasGroup: Boolean,
@ForAll attached: Boolean,
@ForAll active: Boolean,
) {
val courses = mockk<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
val groupId = UUID.randomUUID()
val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
if (hasGroup) {
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
if (attached) listOf(CourseGroup(course = course, groupId = groupId)) else emptyList()
every { courses.findAllById(any()) } returns listOf(course)
} else {
every { identity.currentGroupIds(TOKEN) } returns emptyList()
}
val thrown =
catchThrowable {
service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_STUDENT))
}
val expected = hasGroup && attached && active
if (expected) {
assertThat(thrown).isNull()
} else {
assertThat(thrown).isInstanceOf(ForbiddenException::class.java)
}
}
@Suppress("ktlint:standard:function-naming")
@Property(tries = 20)
fun `admin always bypasses course checks`(
@ForAll active: Boolean,
) {
val service =
CourseAccessService(
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
mockk(),
)
val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED)
assertThat(catchThrowable { service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_ADMIN)) }).isNull()
}
private fun course(status: String): Course = Course(title = "Курс", status = status)
private fun jwt(
role: String,
subject: UUID = UUID.randomUUID(),
): Jwt =
Jwt
.withTokenValue(TOKEN)
.header("alg", "none")
.subject(subject.toString())
.claim("role", role)
.build()
private companion object {
const val TOKEN = "token"
}
}

View File

@@ -2,6 +2,57 @@ plugins {
id("lms.spring-boot")
}
/** PIT через CLI: Gradle-плагин 1.15 несовместим с Gradle 9.6, JUnit5-плагин — с JUnit Platform 6. */
val pitest by configurations.creating
dependencies {
add("pitest", libs.pitestCommandLine)
}
tasks.register<JavaExec>("pitestPromotionPlanner") {
group = "verification"
description = "Mutation testing PromotionPlanner (P0 promotion rules)"
dependsOn(tasks.testClasses)
classpath = pitest + sourceSets.getByName("test").runtimeClasspath
mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport")
val reportDir =
layout.buildDirectory
.dir("reports/pitest-promotion-planner")
.get()
.asFile
.absolutePath
val sourceDirs =
sourceSets.main
.get()
.kotlin.srcDirs
.joinToString(",") { it.absolutePath }
val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath
args(
"--reportDir",
reportDir,
"--targetClasses",
"ru.ysu.lms.identity.promotion.PromotionPlanner",
"--targetTests",
"ru.ysu.lms.identity.promotion.PromotionPlannerMutationTest",
"--sourceDirs",
sourceDirs,
"--classPath",
testClasspath,
"--mutators",
"STRONGER",
"--threads",
"1",
"--outputFormats",
"XML,HTML",
"--timestampedReports",
"false",
"--timeoutConst",
"15000",
"--failWhenNoMutations",
"true",
)
}
dependencies {
implementation(platform(libs.springBootBom))
@@ -15,6 +66,8 @@ dependencies {
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(libs.apachePoiOoxml)
@@ -24,6 +77,8 @@ dependencies {
testImplementation(project(":libs:test-support"))
testImplementation(libs.springBootStarterTest)
testImplementation(libs.springSecurityTest)
// PIT JUnit5-плагин несовместим с JUnit Platform 6; mutation harness — JUnit 4.
testImplementation("junit:junit:4.13.2")
}
springBoot {

View File

@@ -0,0 +1,248 @@
package ru.ysu.lms.identity.promotion
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
/** JUnit 4 harness для PIT; обычный `test` его не запускает. */
class PromotionPlannerMutationTest {
private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC)
@Test
fun `group promotion and graduation follow limits`() {
val random = Random(17)
repeat(25) {
val groups = (1..random.nextInt(1, 6)).map { index -> group(random, index) }
val planner = planner(groups)
val plan = planner.plan(2026)
val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId }
for (group in groups.filter { it.status == GroupStatus.ACTIVE }) {
val item = items.getValue(group.id)
val course = group.course!!.toInt()
val limit = if (group.degree == "БО") 4 else 2
assertThat(item.before["course"]).isEqualTo(course)
if (course < limit) {
assertThat(item.action).isEqualTo(PromotionAction.PROMOTE)
assertThat(item.after["course"]).isEqualTo(course + 1)
assertThat(item.after["name"])
.isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}")
} else {
assertThat(item.action).isEqualTo(PromotionAction.GRADUATE)
assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name)
}
}
}
}
@Test
fun `summary counts graduates academic moves and warnings`() {
val promote = group(index = 1, course = 1)
val graduate = group(index = 2, course = 4)
val inactive = group(index = 3, course = 1, status = GroupStatus.ARCHIVED)
val invalid = group(index = 4, course = 1, degree = "XX")
val activeStudent = student(StudentStatus.ACTIVE)
val academicStudent = student(StudentStatus.ACADEMIC_LEAVE)
val expelledStudent = student(StudentStatus.EXPELLED)
val enrollments =
listOf(
Enrollment(group = graduate, student = activeStudent),
Enrollment(group = promote, student = academicStudent),
Enrollment(group = promote, student = expelledStudent),
)
val planner =
planner(
groups = listOf(promote, graduate, inactive, invalid),
students = listOf(activeStudent, academicStudent, expelledStudent),
enrollments = enrollments,
)
val plan = planner.plan(2026)
@Suppress("UNCHECKED_CAST")
val counts = plan.result["counts"] as Map<String, Any?>
assertThat(counts["groupsPromote"]).isEqualTo(1)
assertThat(counts["groupsGraduate"]).isEqualTo(1)
assertThat(counts["studentsGraduate"]).isEqualTo(1)
assertThat(counts["studentsAcademic"]).isEqualTo(1)
@Suppress("UNCHECKED_CAST")
val warnings = plan.result["warnings"] as List<String>
assertThat(warnings).anyMatch { it.contains("некорректны") }
val graduateItem = plan.items.first { it.entityId == graduate.id }
assertThat(graduateItem.before).containsEntry("status", GroupStatus.ACTIVE.name)
assertThat(graduateItem.after).containsEntry("status", GroupStatus.GRADUATED.name)
val activeItem = plan.items.first { it.entityId == activeStudent.accountId }
assertThat(activeItem.after).containsEntry("status", StudentStatus.GRADUATED.name)
assertThat(plan.items.map { it.entityId }).doesNotContain(inactive.id)
}
@Test
fun `academic leave uses existing target and skips incomplete sources`() {
val source = group(index = 5, course = 2)
val target = group(index = 6, course = 2)
val student = student(StudentStatus.ACADEMIC_LEAVE)
val enrollment = Enrollment(group = source, student = student)
val planner = planner(listOf(source, target), listOf(student), listOf(enrollment), existingTarget = target)
val plan = planner.plan(2026)
val item = plan.items.first { it.entityId == student.accountId }
@Suppress("UNCHECKED_CAST")
val targetState = item.after["targetGroup"] as Map<String, Any?>
assertThat(targetState["id"]).isEqualTo(target.id.toString())
assertThat(targetState["created"]).isEqualTo(false)
val noEnrollment = student(StudentStatus.ACADEMIC_LEAVE)
val noEnrollmentPlan = planner(listOf(source), listOf(noEnrollment)).plan(2026)
assertThat(noEnrollmentPlan.items.map { it.entityId }).doesNotContain(noEnrollment.accountId)
@Suppress("UNCHECKED_CAST")
val warnings = noEnrollmentPlan.result["warnings"] as List<String>
assertThat(warnings).anyMatch { it.contains("нет зачисления") }
val incomplete = group(index = 7, course = 1, stream = null)
val incompleteStudent = student(StudentStatus.ACADEMIC_LEAVE)
val incompletePlan =
planner(
listOf(incomplete),
listOf(incompleteStudent),
listOf(Enrollment(group = incomplete, student = incompleteStudent)),
).plan(2026)
assertThat(incompletePlan.items.map { it.entityId }).doesNotContain(incompleteStudent.accountId)
}
@Test
fun `active student outside graduating group is not moved`() {
val group = group(index = 8, course = 1)
val student = student(StudentStatus.ACTIVE)
val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student)))
val plan = planner.plan(2026)
assertThat(plan.items.map { it.entityId }).doesNotContain(student.accountId)
}
@Test
fun `enrollment without student does not break planning`() {
val group = group(index = 9, course = 1)
val student = student(StudentStatus.ACTIVE)
val orphanEnrollment = Enrollment(group = group, student = null)
val planner = planner(listOf(group), listOf(student), listOf(orphanEnrollment))
val plan = planner.plan(2026)
assertThat(plan.items).isNotEmpty
}
@Test
fun `academic student moves to same course of previous cohort`() {
val random = Random(18)
repeat(20) {
val group = group(random, 1, forceActive = true)
val student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-${random.nextInt()}",
lastName = "Иванов",
firstName = "Иван",
status = StudentStatus.ACADEMIC_LEAVE,
)
val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student)))
val plan = planner.plan(2026)
val item =
plan.items.first {
it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId
}
val course = group.course!!.toInt()
assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC)
assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1))
@Suppress("UNCHECKED_CAST")
val target = item.after["targetGroup"] as Map<String, Any?>
assertThat(target["course"]).isEqualTo(course)
assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1))
}
}
private fun group(
random: Random,
index: Int,
forceActive: Boolean = false,
): GroupEntity {
val degree = if (random.nextBoolean()) "БО" else "МО"
val limit = if (degree == "БО") 4 else 2
return GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${index}$degree",
stream = "ПИ",
course = random.nextInt(1, limit + 1).toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = if (forceActive || random.nextBoolean()) GroupStatus.ACTIVE else GroupStatus.ARCHIVED,
)
}
private fun group(
index: Int,
course: Int,
degree: String = "БО",
status: GroupStatus = GroupStatus.ACTIVE,
stream: String? = "ПИ",
): GroupEntity =
GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${index}$degree",
stream = stream,
course = course.toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = status,
)
private fun student(status: StudentStatus): Student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-${UUID.randomUUID()}",
lastName = "Иванов",
firstName = "Иван",
status = status,
)
private fun planner(
groups: List<GroupEntity>,
students: List<Student> = emptyList(),
enrollments: List<Enrollment> = emptyList(),
existingTarget: GroupEntity? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
every { groupRepository.findAll() } returns groups
every { studentRepository.findAll() } returns students
every { enrollmentRepository.findAllWithStudentAndGroup() } returns enrollments
every {
groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
any(),
any(),
any(),
any(),
any(),
)
} returns existingTarget
return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock)
}
}

View File

@@ -0,0 +1,145 @@
package ru.ysu.lms.identity.promotion
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
/**
* Property-style проверки планировщика повышения: не один сценарий, а
* 100 сгенерированных когорт. Проверяются инварианты «курс + 1», выпуск на
* пределе и академический перевод в группу того же курса с годом набора
* `year - (course - 1)`.
*/
class PromotionPlannerPropertyTest {
private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC)
@Test
fun `active groups promote or graduate exactly by degree limit`() {
val random = Random(20261027)
repeat(100) { iteration ->
val groups = (1..random.nextInt(1, 8)).map { index -> randomGroup(random, index) }
val planner = planner(groups = groups)
val plan = planner.plan(2026)
val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId }
val active = groups.filter { it.status == GroupStatus.ACTIVE }
assertThat(items).hasSize(active.size)
for (group in active) {
val item = items.getValue(group.id)
val course = group.course!!.toInt()
val limit = if (group.degree == "БО") 4 else 2
assertThat(item.before["course"]).isEqualTo(course)
if (course < limit) {
assertThat(item.action).isEqualTo(PromotionAction.PROMOTE)
assertThat(item.after["course"]).isEqualTo(course + 1)
assertThat(item.after["name"])
.isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}")
} else {
assertThat(item.action).isEqualTo(PromotionAction.GRADUATE)
assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name)
}
}
val inactive = groups.filter { it.status != GroupStatus.ACTIVE }.map { it.id }
if (inactive.isNotEmpty()) {
assertThat(items.keys).doesNotContainAnyElementsOf(inactive)
}
assertThat(plan.result["counts"]).isNotNull
assertThat(plan.items.size).isEqualTo(items.size)
}
}
@Test
fun `academic leave targets the same course with year minus course minus one`() {
val random = Random(20261028)
repeat(100) { iteration ->
val group = randomGroup(random, 1, forceActive = true)
val student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-$iteration-${random.nextInt()}",
lastName = "Иванов",
firstName = "Иван",
status = StudentStatus.ACADEMIC_LEAVE,
)
val planner =
planner(groups = listOf(group), students = listOf(student), enrollment = Enrollment(group = group, student = student))
val plan = planner.plan(2026)
val item =
plan.items.first {
it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId
}
val course = group.course!!.toInt()
assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC)
assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1))
@Suppress("UNCHECKED_CAST")
val target = item.after["targetGroup"] as Map<String, Any?>
assertThat(target["course"]).isEqualTo(course)
assertThat(target["stream"]).isEqualTo(group.stream)
assertThat(target["degree"]).isEqualTo(group.degree)
assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1))
}
}
private fun randomGroup(
random: Random,
index: Int,
forceActive: Boolean = false,
): GroupEntity {
val degree = if (random.nextBoolean()) "БО" else "МО"
val limit = if (degree == "БО") 4 else 2
return GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${random.nextInt(1, limit + 1)}${index}$degree",
stream = "ПИ",
course = random.nextInt(1, limit + 1).toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = if (forceActive || random.nextInt(5) > 0) GroupStatus.ACTIVE else GroupStatus.ARCHIVED,
)
}
private fun planner(
groups: List<GroupEntity>,
students: List<Student> = emptyList(),
enrollment: Enrollment? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
every { groupRepository.findAll() } returns groups
every { studentRepository.findAll() } returns students
every { enrollmentRepository.findAllWithStudentAndGroup() } returns listOfNotNull(enrollment)
every {
groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
any(),
any(),
any(),
any(),
any(),
)
} returns null
return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock)
}
}

View File

@@ -15,6 +15,8 @@ dependencies {
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.springdocStarterWebmvcUi)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt"))