feat: закрыть остаточный backlog

- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
This commit is contained in:
2026-10-28 19:29:23 +03:00
parent d45137220c
commit 1e1e9ae0e4
64 changed files with 2124 additions and 372 deletions

View File

@@ -54,6 +54,16 @@ class LabController(
return labService.list(accessible)
}
/** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */
@GetMapping("/details")
fun details(
@RequestParam courseId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<LabDetail> {
accessService.requireCourseAccess(courseId, jwt)
return labService.details(courseId)
}
@GetMapping("/{id}")
fun detail(
@PathVariable id: UUID,

View File

@@ -22,7 +22,7 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.UUID
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@Service
class LabService(
private val labs: LabRepository,
@@ -118,45 +118,25 @@ class LabService(
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsRepo
.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
courseId = lab.course?.id?.toString(),
courseNo = lab.course?.courseNo,
courseTitle = lab.course?.title,
return lab.toDetail(
labLevels.findByLabIdOrderByPosition(lab.id),
variantsRepo.findByLabIdOrderByNumber(lab.id),
)
}
/** Все лабораторные курса с уровнями и вариантами: три запроса вместо N+1. */
@Transactional(readOnly = true)
fun details(courseId: UUID): List<LabDetail> {
val labList = labs.findAllByCourseIdOrderByNumberAsc(courseId)
if (labList.isEmpty()) return emptyList()
val labIds = labList.map { it.id }
val levelsByLab = labLevels.findByLabIdInOrderByPosition(labIds).groupBy { it.lab?.id }
val variantsByLab = variantsRepo.findByLabIdInOrderByNumberAsc(labIds).groupBy { it.lab?.id }
return labList.map { lab ->
lab.toDetail(levelsByLab[lab.id].orEmpty(), variantsByLab[lab.id].orEmpty())
}
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
@@ -414,6 +394,49 @@ private fun Course.rejectArchived(): Course {
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
/** Единая сборка деталей лабораторной для одиночного и пакетного ответов. */
private fun Lab.toDetail(
levels: List<LabLevel>,
variants: List<Variant>,
): LabDetail {
val variantsByLevel = variants.groupBy { it.labLevel?.id }
return LabDetail(
id = id.toString(),
number = number,
title = title,
status = status,
descriptionMd = descriptionMd,
deadlineAt = deadlineAt?.toString(),
defenseAt = defenseAt?.toString(),
pointsMax = pointsMax,
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsByLevel[level.id]
.orEmpty()
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
courseId = course?.id?.toString(),
courseNo = course?.courseNo,
courseTitle = course?.title,
)
}
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
LabSummary(
id = id.toString(),

View File

@@ -7,8 +7,12 @@ import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
fun findByLabIdInOrderByPosition(labIds: Collection<UUID>): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
fun findByLabIdInOrderByNumberAsc(labIds: Collection<UUID>): List<Variant>
}

View File

@@ -0,0 +1,268 @@
package ru.ysu.lms.academic.lab
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.academic.course.CourseCreateRequest
import ru.ysu.lms.academic.course.CourseGroupRequest
import ru.ysu.lms.academic.course.CourseService
import ru.ysu.lms.academic.course.CourseTeacherRequest
import ru.ysu.lms.academic.identity.IdentityClient
import java.util.UUID
/**
* Пакетные детали лабораторных курса: один запрос отдаёт все лабораторные с их
* уровнями и вариантами, без утечки вариантов между уровнями и лабораторными.
* Доступ — тот же, что у `GET /api/v1/labs?courseId=`: `requireCourseAccess`.
*/
@Testcontainers
@SpringBootTest
@Transactional
class LabDetailsIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var context: WebApplicationContext
@Autowired
private lateinit var courseService: CourseService
@Autowired
private lateinit var labService: LabService
@MockitoBean
private lateinit var identity: IdentityClient
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `teacher gets all course labs with levels and variants in one call`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
seedFirstLab(courseId)
seedSecondLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].number").value(1))
.andExpect(jsonPath("$[0].title").value("Первая лабораторная"))
.andExpect(jsonPath("$[0].courseId").value(courseId.toString()))
.andExpect(jsonPath("$[0].levels.length()").value(2))
.andExpect(jsonPath("$[0].levels[0].code").value("basic"))
.andExpect(jsonPath("$[0].levels[0].title").value("Базовый уровень"))
.andExpect(jsonPath("$[0].levels[0].variants.length()").value(2))
.andExpect(jsonPath("$[0].levels[0].variants[0].number").value("2"))
.andExpect(jsonPath("$[0].levels[0].variants[0].title").value("Б2"))
.andExpect(jsonPath("$[0].levels[0].variants[1].number").value("10"))
.andExpect(jsonPath("$[0].levels[0].variants[1].title").value("Б10"))
.andExpect(jsonPath("$[0].levels[1].code").value("advanced"))
.andExpect(jsonPath("$[0].levels[1].title").value("Продвинутый уровень"))
.andExpect(jsonPath("$[0].levels[1].variants.length()").value(1))
.andExpect(jsonPath("$[0].levels[1].variants[0].number").value("1"))
.andExpect(jsonPath("$[0].levels[1].variants[0].title").value("А1"))
.andExpect(jsonPath("$[1].number").value(2))
.andExpect(jsonPath("$[1].title").value("Вторая лабораторная"))
.andExpect(jsonPath("$[1].levels.length()").value(1))
.andExpect(jsonPath("$[1].levels[0].code").value("basic"))
.andExpect(jsonPath("$[1].levels[0].variants.length()").value(1))
.andExpect(jsonPath("$[1].levels[0].variants[0].number").value("3"))
.andExpect(jsonPath("$[1].levels[0].variants[0].title").value("В3"))
}
@Test
fun `admin gets all labs of any course in one call`() {
val courseId = newCourse()
seedFirstLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "ADMIN")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].number").value(1))
.andExpect(jsonPath("$[0].levels.length()").value(2))
}
@Test
fun `foreign teacher is forbidden for lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
).andExpect(status().isForbidden)
}
@Test
fun `student of the course receives lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
val groupId = UUID.randomUUID()
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].levels.length()").value(2))
}
@Test
fun `student outside the course is forbidden for lab details`() {
val courseId = newCourse()
seedFirstLab(courseId)
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(UUID.randomUUID()))
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
).andExpect(status().isForbidden)
}
@Test
fun `course without labs returns an empty details list`() {
val courseId = newCourse()
val teacherId = UUID.randomUUID()
assignTeacher(courseId, teacherId)
mockMvc
.perform(
get("/api/v1/labs/details")
.param("courseId", courseId.toString())
.with(jwtFor(teacherId, "TEACHER")),
).andExpect(status().isOk)
.andExpect(jsonPath("$.length()").value(0))
}
private fun newCourse(): UUID =
UUID.fromString(
courseService
.create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID())
.id,
)
private fun assignTeacher(
courseId: UUID,
teacherId: UUID,
) {
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
}
private fun seedFirstLab(courseId: UUID) {
createLab(
courseId = courseId,
number = 1,
title = "Первая лабораторная",
levels =
listOf(
LevelSaveRequest(code = "basic", title = "Базовый уровень"),
LevelSaveRequest(code = "advanced", title = "Продвинутый уровень"),
),
variants =
listOf(
SeededVariant("basic", "10", "Б10"),
SeededVariant("basic", "2", "Б2"),
SeededVariant("advanced", "1", "А1"),
),
)
}
private fun seedSecondLab(courseId: UUID) {
createLab(
courseId = courseId,
number = 2,
title = "Вторая лабораторная",
levels = listOf(LevelSaveRequest(code = "basic", title = "Базовый уровень")),
variants = listOf(SeededVariant("basic", "3", "В3")),
)
}
private fun createLab(
courseId: UUID,
number: Int,
title: String,
levels: List<LevelSaveRequest>,
variants: List<SeededVariant>,
) {
val created =
labService.create(
LabCreateRequest(
number = number,
title = title,
courseId = courseId,
levels = levels,
),
)
val levelIdsByCode = created.levels.associate { it.code to it.id }
val labId = UUID.fromString(created.id)
variants.forEach { variant ->
labService.addVariant(
labId,
VariantSaveRequest(
labLevelId = levelIdsByCode.getValue(variant.levelCode),
number = variant.number,
title = variant.title,
statementMd = "Условие ${variant.title}",
),
)
}
}
private fun jwtFor(
subject: UUID,
role: String,
) = jwt()
.authorities(SimpleGrantedAuthority("ROLE_$role"))
.jwt { it.subject(subject.toString()).claim("role", role) }
private data class SeededVariant(
val levelCode: String,
val number: String,
val title: String,
)
}