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:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user