feat(ldd): property-based, mutation и браузерный E2E
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user