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,
|
||||
)
|
||||
}
|
||||
@@ -18,5 +18,6 @@ class AuditController(
|
||||
@RequestParam(defaultValue = "100") limit: Int,
|
||||
@RequestParam(required = false) action: String?,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
): List<AuditEventDto> = auditService.list(limit, action, page)
|
||||
@RequestParam(required = false) cursor: String?,
|
||||
): List<AuditEventDto> = auditService.list(limit, action, page, cursor)
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@ data class AuditEventDto(
|
||||
val courseId: String?,
|
||||
val studentId: String?,
|
||||
val details: String,
|
||||
/** Непрозрачный курсор для следующей keyset-страницы. */
|
||||
val cursor: String? = null,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,9 @@ package ru.ysu.lms.identity.audit
|
||||
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
|
||||
@@ -13,4 +16,20 @@ interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
|
||||
action: String,
|
||||
pageable: Pageable,
|
||||
): List<AuditEvent>
|
||||
|
||||
/** Keyset-страница: строго после курсорной пары `(occurredAt, id)` при сортировке DESC. */
|
||||
@Query(
|
||||
"""
|
||||
select e from AuditEvent e
|
||||
where (:action is null or e.action = :action)
|
||||
and (e.occurredAt < :beforeAt or (e.occurredAt = :beforeAt and e.id < :beforeId))
|
||||
order by e.occurredAt desc, e.id desc
|
||||
""",
|
||||
)
|
||||
fun findBefore(
|
||||
@Param("action") action: String?,
|
||||
@Param("beforeAt") beforeAt: Instant,
|
||||
@Param("beforeId") beforeId: UUID,
|
||||
pageable: Pageable,
|
||||
): List<AuditEvent>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -39,16 +41,22 @@ class AuditService(
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
action: String? = null,
|
||||
page: Int = 0,
|
||||
cursor: String? = null,
|
||||
): List<AuditEventDto> {
|
||||
val size = limit.coerceIn(1, MAX_LIMIT)
|
||||
val pageIndex = page.coerceAtLeast(0)
|
||||
val pageable = PageRequest.of(pageIndex, size)
|
||||
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val found =
|
||||
if (filter == null) {
|
||||
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
|
||||
if (cursor != null) {
|
||||
val (beforeAt, beforeId) = decodeCursor(cursor)
|
||||
events.findBefore(filter, beforeAt, beforeId, PageRequest.of(0, size))
|
||||
} else {
|
||||
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
|
||||
val pageIndex = page.coerceAtLeast(0)
|
||||
val pageable = PageRequest.of(pageIndex, size)
|
||||
if (filter == null) {
|
||||
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
|
||||
} else {
|
||||
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
|
||||
}
|
||||
}
|
||||
return found.map(::toDto)
|
||||
}
|
||||
@@ -65,8 +73,30 @@ class AuditService(
|
||||
courseId = event.courseId?.toString(),
|
||||
studentId = event.studentId?.toString(),
|
||||
details = event.details,
|
||||
cursor = encodeCursor(event.occurredAt, event.id),
|
||||
)
|
||||
|
||||
/** Курсор — URL-safe Base64 без паддинга от пары `occurredAt|id`. */
|
||||
private fun encodeCursor(
|
||||
occurredAt: Instant,
|
||||
id: UUID,
|
||||
): String =
|
||||
Base64
|
||||
.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString("$occurredAt|$id".toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun decodeCursor(raw: String): Pair<Instant, UUID> {
|
||||
val parts =
|
||||
runCatching { String(Base64.getUrlDecoder().decode(raw), Charsets.UTF_8) }
|
||||
.getOrNull()
|
||||
?.split("|", limit = 2)
|
||||
val occurredAt = parts?.getOrNull(0)?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
val id = parts?.getOrNull(1)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
if (occurredAt == null || id == null) throw BadRequestException(INVALID_CURSOR)
|
||||
return occurredAt to id
|
||||
}
|
||||
|
||||
private fun requireText(
|
||||
raw: String,
|
||||
field: String,
|
||||
@@ -97,5 +127,6 @@ class AuditService(
|
||||
const val ENTITY_ID_MAX = 128
|
||||
const val ACTOR_LOGIN_MAX = 100
|
||||
const val DETAILS_MAX = 2000
|
||||
const val INVALID_CURSOR = "Некорректный курсор аудита"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ class RefreshToken(
|
||||
var issuedAt: Instant = Instant.now(),
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
var expiresAt: Instant = Instant.now(),
|
||||
@Column(name = "absolute_expires_at", nullable = false)
|
||||
var absoluteExpiresAt: Instant = Instant.now(),
|
||||
@Column(name = "revoked_at")
|
||||
var revokedAt: Instant? = null,
|
||||
@Column(name = "replaced_by")
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package ru.ysu.lms.identity.auth.refresh
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
|
||||
fun findByTokenHash(tokenHash: String): RefreshToken?
|
||||
|
||||
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<RefreshToken>
|
||||
|
||||
/** Удаляет и sliding-, и absolute-мёртвые токены, чтобы цепочка не жила лишние дни. */
|
||||
@Modifying
|
||||
@Query("delete from RefreshToken t where t.expiresAt < :cutoff or t.absoluteExpiresAt < :cutoff")
|
||||
fun deleteExpiredBefore(
|
||||
@Param("cutoff") cutoff: Instant,
|
||||
): Int
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ru.ysu.lms.identity.auth.refresh
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.Ids
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
@@ -23,6 +26,8 @@ data class RefreshTokenRotation(
|
||||
/**
|
||||
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
|
||||
* а сессия продлевается скользяще: каждый refresh возвращает новую пару.
|
||||
* Абсолютный срок (`absoluteExpiresAt`) при этом не продлевается: сессия
|
||||
* живёт не дольше [absoluteTtl] с момента выдачи.
|
||||
*/
|
||||
@Service
|
||||
class RefreshTokenService(
|
||||
@@ -30,7 +35,10 @@ class RefreshTokenService(
|
||||
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
|
||||
@Lazy private val authService: AuthService,
|
||||
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration,
|
||||
@Value("\${lms.auth.absolute-session-ttl:30d}") private val absoluteTtl: Duration,
|
||||
@Value("\${lms.auth.refresh-token-retention:30d}") private val retention: Duration,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(RefreshTokenService::class.java)
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun issue(accountId: UUID): String {
|
||||
@@ -48,13 +56,16 @@ class RefreshTokenService(
|
||||
fun rotate(raw: String): RefreshTokenRotation {
|
||||
val token = activeTokenOrThrow(raw)
|
||||
val now = Instant.now()
|
||||
if (!token.absoluteExpiresAt.isAfter(now)) {
|
||||
throw UnauthorizedException(SESSION_EXPIRED)
|
||||
}
|
||||
authService.ensureSessionAllowed(token.accountId)
|
||||
|
||||
val newId = Ids.v7()
|
||||
val newRaw = generateRaw()
|
||||
token.revokedAt = now
|
||||
token.replacedBy = newId
|
||||
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId)))
|
||||
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId, token.absoluteExpiresAt)))
|
||||
return RefreshTokenRotation(token.accountId, newRaw)
|
||||
}
|
||||
|
||||
@@ -95,11 +106,32 @@ class RefreshTokenService(
|
||||
tokens.saveAll(active)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет токены, истёкшие раньше `now - retention`. Отозванные, но ещё
|
||||
* не истёкшие токены сохраняются: по ним детектируется reuse.
|
||||
*/
|
||||
@Transactional
|
||||
fun cleanupExpired(now: Instant = Instant.now()): Int {
|
||||
val cutoff = now.minus(retention)
|
||||
val deleted = tokens.deleteExpiredBefore(cutoff)
|
||||
if (deleted > 0) {
|
||||
log.info("Удалено истёкших refresh-токенов: {}", deleted)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
@Scheduled(cron = "\${lms.auth.refresh-token-cleanup-cron:0 30 3 * * *}")
|
||||
@Transactional
|
||||
fun cleanupExpiredTokens() {
|
||||
cleanupExpired()
|
||||
}
|
||||
|
||||
private fun newToken(
|
||||
accountId: UUID,
|
||||
raw: String,
|
||||
now: Instant,
|
||||
id: UUID = Ids.v7(),
|
||||
absoluteExpiresAt: Instant = now.plus(absoluteTtl),
|
||||
): RefreshToken =
|
||||
RefreshToken(
|
||||
id = id,
|
||||
@@ -107,6 +139,7 @@ class RefreshTokenService(
|
||||
tokenHash = hash(raw),
|
||||
issuedAt = now,
|
||||
expiresAt = now.plus(ttl),
|
||||
absoluteExpiresAt = absoluteExpiresAt,
|
||||
)
|
||||
|
||||
private fun generateRaw(): String {
|
||||
|
||||
@@ -45,3 +45,8 @@ lms:
|
||||
auth:
|
||||
# Скользящая сессия: каждый refresh продлевает её на этот срок.
|
||||
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d}
|
||||
# Абсолютный потолок сессии: ротация не может продлить её дальше этого срока.
|
||||
absolute-session-ttl: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d}
|
||||
# Сколько хранить истёкшие refresh-токены перед очисткой.
|
||||
refresh-token-retention: ${LMS_REFRESH_TOKEN_RETENTION:30d}
|
||||
refresh-token-cleanup-cron: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:0 30 3 * * *}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Абсолютный срок жизни сессии: ротация refresh-токена продлевает скользящее
|
||||
-- окно, но не может растянуть сессию дальше absolute_expires_at.
|
||||
-- Бэкфилл существующих строк использует дефолтные 30 дней; если на стенде
|
||||
-- LMS_SESSION_ABSOLUTE_TIMEOUT меньше, старые сессии доживают один раз до 30 дней.
|
||||
ALTER TABLE identity.refresh_tokens ADD COLUMN absolute_expires_at timestamptz;
|
||||
UPDATE identity.refresh_tokens SET absolute_expires_at = issued_at + interval '30 days' WHERE absolute_expires_at IS NULL;
|
||||
ALTER TABLE identity.refresh_tokens ALTER COLUMN absolute_expires_at SET NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Индексы под keyset-пагинацию: сортировка (occurred_at DESC, id DESC) и та же
|
||||
-- сортировка с фильтром по action.
|
||||
CREATE INDEX audit_events_occurred_id_idx ON identity.audit_events (occurred_at DESC, id DESC);
|
||||
CREATE INDEX audit_events_action_occurred_id_idx ON identity.audit_events (action, occurred_at DESC, id DESC);
|
||||
@@ -25,6 +25,8 @@ import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.identity.auth.ChangePasswordRequest
|
||||
import ru.ysu.lms.identity.auth.LoginRequest
|
||||
import ru.ysu.lms.identity.auth.RefreshTokenRequest
|
||||
import ru.ysu.lms.identity.auth.refresh.RefreshToken
|
||||
import ru.ysu.lms.identity.auth.refresh.RefreshTokenRepository
|
||||
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
|
||||
import ru.ysu.lms.identity.roster.CreateGroupRequest
|
||||
import ru.ysu.lms.identity.roster.CreateStudentRequest
|
||||
@@ -32,6 +34,7 @@ import ru.ysu.lms.identity.roster.Credential
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import ru.ysu.lms.identity.roster.StudentLifecycleService
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -62,6 +65,8 @@ class RefreshTokenIntegrationTest {
|
||||
|
||||
@Autowired lateinit var refreshTokens: RefreshTokenService
|
||||
|
||||
@Autowired lateinit var refreshTokenRepository: RefreshTokenRepository
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var lifecycle: StudentLifecycleService
|
||||
@@ -188,6 +193,93 @@ class RefreshTokenIntegrationTest {
|
||||
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh past the absolute session expiry is rejected with unauthorized`() {
|
||||
val student = createStudent()
|
||||
val refreshToken = login(student).refreshToken!!
|
||||
val token = activeToken(UUID.fromString(student.id))
|
||||
token.absoluteExpiresAt = Instant.now().minusSeconds(1)
|
||||
refreshTokenRepository.save(token)
|
||||
|
||||
mockMvc
|
||||
.post("/api/v1/auth/refresh") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonMapper.writeValueAsString(RefreshTokenRequest(refreshToken))
|
||||
}.andExpect {
|
||||
status { isUnauthorized() }
|
||||
}
|
||||
|
||||
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
|
||||
.isInstanceOf(UnauthorizedException::class.java)
|
||||
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotation keeps the absolute session expiry unchanged`() {
|
||||
val student = createStudent()
|
||||
val accountId = UUID.fromString(student.id)
|
||||
val original = login(student).refreshToken!!
|
||||
val absoluteExpiresAt = activeToken(accountId).absoluteExpiresAt
|
||||
assertThat(absoluteExpiresAt).isAfter(Instant.now().plus(Duration.ofDays(29)))
|
||||
|
||||
val rotated = refreshTokens.rotate(original)
|
||||
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
|
||||
|
||||
val rotatedAgain = refreshTokens.rotate(rotated.refreshToken)
|
||||
assertThat(rotatedAgain.accountId).isEqualTo(accountId)
|
||||
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleanup deletes only tokens expired beyond retention`() {
|
||||
val student = createStudent()
|
||||
val accountId = UUID.fromString(student.id)
|
||||
val now = Instant.parse("2026-01-31T00:00:00Z")
|
||||
|
||||
val expired =
|
||||
token(
|
||||
accountId,
|
||||
"a".repeat(64),
|
||||
now.minus(Duration.ofDays(40)),
|
||||
now.minus(Duration.ofDays(31)),
|
||||
now.minus(Duration.ofDays(10)),
|
||||
)
|
||||
val revokedRecently =
|
||||
token(
|
||||
accountId,
|
||||
"b".repeat(64),
|
||||
now.minus(Duration.ofDays(2)),
|
||||
now.minus(Duration.ofDays(1)),
|
||||
now.plus(Duration.ofDays(28)),
|
||||
)
|
||||
revokedRecently.revokedAt = now.minus(Duration.ofHours(2))
|
||||
val absoluteDead =
|
||||
token(
|
||||
accountId,
|
||||
"d".repeat(64),
|
||||
now.minus(Duration.ofDays(45)),
|
||||
now.plus(Duration.ofDays(7)),
|
||||
now.minus(Duration.ofDays(40)),
|
||||
)
|
||||
val fresh =
|
||||
token(
|
||||
accountId,
|
||||
"c".repeat(64),
|
||||
now,
|
||||
now.plus(Duration.ofDays(7)),
|
||||
now.plus(Duration.ofDays(30)),
|
||||
)
|
||||
refreshTokenRepository.saveAll(listOf(expired, revokedRecently, absoluteDead, fresh))
|
||||
|
||||
val deleted = refreshTokens.cleanupExpired(now)
|
||||
|
||||
assertThat(deleted).isEqualTo(2)
|
||||
assertThat(refreshTokenRepository.findById(expired.id)).isEmpty
|
||||
assertThat(refreshTokenRepository.findById(absoluteDead.id)).isEmpty
|
||||
assertThat(refreshTokenRepository.findById(revokedRecently.id)).isPresent
|
||||
assertThat(refreshTokenRepository.findById(fresh.id)).isPresent
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logout revokes the refresh token idempotently`() {
|
||||
val student = createStudent()
|
||||
@@ -252,6 +344,23 @@ class RefreshTokenIntegrationTest {
|
||||
.hasMessage("Группа закрыта")
|
||||
}
|
||||
|
||||
private fun activeToken(accountId: UUID): RefreshToken = refreshTokenRepository.findAllByAccountIdAndRevokedAtIsNull(accountId).single()
|
||||
|
||||
private fun token(
|
||||
accountId: UUID,
|
||||
tokenHash: String,
|
||||
issuedAt: Instant,
|
||||
expiresAt: Instant,
|
||||
absoluteExpiresAt: Instant,
|
||||
): RefreshToken =
|
||||
RefreshToken(
|
||||
accountId = accountId,
|
||||
tokenHash = tokenHash,
|
||||
issuedAt = issuedAt,
|
||||
expiresAt = expiresAt,
|
||||
absoluteExpiresAt = absoluteExpiresAt,
|
||||
)
|
||||
|
||||
private fun createStudent(): StudentFixture {
|
||||
val number = sequence.incrementAndGet()
|
||||
val group =
|
||||
|
||||
@@ -216,6 +216,65 @@ class AuditIntegrationTest {
|
||||
assertTrue((first + second).intersect(third.toSet()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor pagination walks all events newest first`() {
|
||||
repeat(5) { index ->
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR", entityType = "STUDENT", details = "cursor-${index + 1}"))
|
||||
}
|
||||
|
||||
val first = auditPage(limit = 2, page = 0)
|
||||
assertEquals(listOf("cursor-5", "cursor-4"), first.details)
|
||||
assertTrue(first.cursor != null)
|
||||
|
||||
val second = auditPage(limit = 2, cursor = first.cursor)
|
||||
assertEquals(listOf("cursor-3", "cursor-2"), second.details)
|
||||
assertTrue(first.details.intersect(second.details.toSet()).isEmpty())
|
||||
|
||||
val third = auditPage(limit = 2, cursor = second.cursor)
|
||||
assertEquals(listOf("cursor-1"), third.details)
|
||||
assertTrue((first.details + second.details).intersect(third.details.toSet()).isEmpty())
|
||||
|
||||
val all = first.details + second.details + third.details
|
||||
assertEquals(5, all.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor pagination respects the action filter`() {
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a1"))
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR_OTHER", entityType = "STUDENT", details = "x"))
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a2"))
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a3"))
|
||||
|
||||
val first = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER")
|
||||
assertEquals(listOf("a3", "a2"), first.details)
|
||||
|
||||
val second = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER", cursor = first.cursor)
|
||||
assertEquals(listOf("a1"), second.details)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed audit cursor is rejected with bad request`() {
|
||||
mockMvc
|
||||
.get("/api/v1/audit") {
|
||||
with(admin())
|
||||
param("cursor", "%%%not-base64%%%")
|
||||
}.andExpect {
|
||||
status { isBadRequest() }
|
||||
jsonPath("$.detail") { value("Некорректный курсор аудита") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cursor from the newest event returns an empty page`() {
|
||||
write(AuditEventRequest(action = "AUDIT_CURSOR_END", entityType = "STUDENT", details = "only"))
|
||||
|
||||
val first = auditPage(limit = 1, action = "AUDIT_CURSOR_END")
|
||||
assertEquals(listOf("only"), first.details)
|
||||
|
||||
val next = auditPage(limit = 5, action = "AUDIT_CURSOR_END", cursor = first.cursor)
|
||||
assertEquals(emptyList<String>(), next.details)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative page is clamped to the first page and page past the end is empty`() {
|
||||
repeat(3) { index ->
|
||||
@@ -319,6 +378,42 @@ class AuditIntegrationTest {
|
||||
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
|
||||
}
|
||||
|
||||
private data class AuditPage(
|
||||
val details: List<String>,
|
||||
val cursor: String?,
|
||||
)
|
||||
|
||||
private fun auditPage(
|
||||
limit: Int,
|
||||
page: Int? = null,
|
||||
cursor: String? = null,
|
||||
action: String? = null,
|
||||
): AuditPage {
|
||||
val body =
|
||||
mockMvc
|
||||
.get("/api/v1/audit") {
|
||||
with(admin())
|
||||
param("limit", limit.toString())
|
||||
page?.let { param("page", it.toString()) }
|
||||
cursor?.let { param("cursor", it) }
|
||||
action?.let { param("action", it) }
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
}.andReturn()
|
||||
.response
|
||||
.getContentAsString(StandardCharsets.UTF_8)
|
||||
val nodes = objectMapper.readTree(body)
|
||||
return AuditPage(
|
||||
details = nodes.map { it.path("details").asText() },
|
||||
cursor =
|
||||
nodes
|
||||
.lastOrNull()
|
||||
?.path("cursor")
|
||||
?.asText()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun detailsOfPage(
|
||||
limit: Int,
|
||||
page: Int,
|
||||
|
||||
@@ -80,37 +80,27 @@ class AcademicClient(
|
||||
}
|
||||
|
||||
/**
|
||||
* Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки,
|
||||
* поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не
|
||||
* должна ронять весь профиль — остаётся сводка без уровней.
|
||||
* Лабораторные курса вместе с уровнями и вариантами: один пакетный запрос
|
||||
* деталей на весь курс, без N+1 запросов на каждую лабораторную.
|
||||
*/
|
||||
fun getCourseLabs(
|
||||
courseId: String,
|
||||
token: String,
|
||||
): List<LabInfo> {
|
||||
val type = object : ParameterizedTypeReference<List<LabInfo>>() {}
|
||||
val summaries =
|
||||
val labs =
|
||||
try {
|
||||
client
|
||||
.get()
|
||||
.uri("/api/v1/labs?courseId={courseId}", courseId)
|
||||
.uri("/api/v1/labs/details?courseId={courseId}", courseId)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve()
|
||||
.body(type) ?: emptyList()
|
||||
} catch (ex: RestClientResponseException) {
|
||||
throw courseError(ex)
|
||||
}
|
||||
return summaries.map { summary ->
|
||||
if (summary.id.isBlank()) {
|
||||
summary
|
||||
} else {
|
||||
val detail = runCatching { getLab(summary.id, token) }.getOrNull() ?: summary
|
||||
detail.copy(
|
||||
courseId = detail.courseId ?: summary.courseId ?: courseId,
|
||||
courseNo = detail.courseNo ?: summary.courseNo,
|
||||
courseTitle = detail.courseTitle ?: summary.courseTitle,
|
||||
)
|
||||
}
|
||||
return labs.map { lab ->
|
||||
if (lab.courseId.isNullOrBlank()) lab.copy(courseId = courseId) else lab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import ru.ysu.lms.common.ForbiddenException
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/** Пакетная загрузка лабораторных курса: один запрос вместо N+1 по каждой лабораторной. */
|
||||
class AcademicClientBatchLabsTest {
|
||||
private lateinit var server: HttpServer
|
||||
private val requestedUris = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
private var responseStatus = 200
|
||||
|
||||
@Volatile
|
||||
private var responseBody = "[]"
|
||||
|
||||
@BeforeEach
|
||||
fun startServer() {
|
||||
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.createContext(DETAILS_PATH) { exchange ->
|
||||
requestedUris += exchange.requestURI.toString()
|
||||
val bytes = responseBody.toByteArray(Charsets.UTF_8)
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(responseStatus, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun stopServer() {
|
||||
server.stop(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCourseLabs loads all labs with levels and variants in one request`() {
|
||||
respond(200, LABS_JSON)
|
||||
|
||||
val labs = client().getCourseLabs(COURSE_ID, TOKEN)
|
||||
|
||||
assertThat(requestedUris).hasSize(1)
|
||||
assertThat(requestedUris.single())
|
||||
.startsWith(DETAILS_PATH)
|
||||
.contains("courseId=$COURSE_ID")
|
||||
assertThat(labs).hasSize(2)
|
||||
|
||||
val first = labs[0]
|
||||
assertThat(first.id).isEqualTo(LAB_ONE_ID)
|
||||
assertThat(first.number).isEqualTo(1)
|
||||
assertThat(first.title).isEqualTo("Лабораторная 1")
|
||||
assertThat(first.courseId).isEqualTo(COURSE_ID)
|
||||
assertThat(first.levels).hasSize(1)
|
||||
|
||||
val firstLevel = first.levels.single()
|
||||
assertThat(firstLevel.code).isEqualTo("L1")
|
||||
assertThat(firstLevel.variants).hasSize(2)
|
||||
assertThat(firstLevel.variants.map { it.number }).containsExactly("1", "2")
|
||||
assertThat(firstLevel.variants[1].statementMd).isEqualTo("Условие 1.2")
|
||||
|
||||
val second = labs[1]
|
||||
assertThat(second.id).isEqualTo(LAB_TWO_ID)
|
||||
assertThat(second.levels).hasSize(1)
|
||||
|
||||
val secondLevel = second.levels.single()
|
||||
assertThat(secondLevel.variants.single().id).isEqualTo(VARIANT_TWO_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCourseLabs falls back to requested course id when response omits it`() {
|
||||
respond(200, LABS_WITHOUT_COURSE_JSON)
|
||||
|
||||
val labs = client().getCourseLabs(COURSE_ID, TOKEN)
|
||||
|
||||
assertThat(requestedUris).hasSize(1)
|
||||
assertThat(labs).hasSize(2)
|
||||
assertThat(labs).allSatisfy { lab -> assertThat(lab.courseId).isEqualTo(COURSE_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCourseLabs maps forbidden response to ForbiddenException`() {
|
||||
respond(403, "{}")
|
||||
|
||||
assertThatThrownBy { client().getCourseLabs(COURSE_ID, TOKEN) }
|
||||
.isInstanceOf(ForbiddenException::class.java)
|
||||
.hasMessage("Курс недоступен")
|
||||
assertThat(requestedUris).hasSize(1)
|
||||
}
|
||||
|
||||
private fun respond(
|
||||
status: Int,
|
||||
body: String,
|
||||
) {
|
||||
responseStatus = status
|
||||
responseBody = body
|
||||
}
|
||||
|
||||
private fun client(): AcademicClient = AcademicClient(baseUrl = "http://127.0.0.1:${server.address.port}")
|
||||
|
||||
private companion object {
|
||||
const val DETAILS_PATH = "/api/v1/labs/details"
|
||||
const val TOKEN = "test-token"
|
||||
const val COURSE_ID = "44444444-4444-4444-4444-444444444444"
|
||||
const val LAB_ONE_ID = "11111111-1111-1111-1111-111111111111"
|
||||
const val LAB_TWO_ID = "22222222-2222-2222-2222-222222222222"
|
||||
const val VARIANT_TWO_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
val LABS_JSON =
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "$LAB_ONE_ID",
|
||||
"number": 1,
|
||||
"title": "Лабораторная 1",
|
||||
"status": "PUBLISHED",
|
||||
"descriptionMd": "Описание 1",
|
||||
"deadlineAt": "2026-11-01T00:00:00Z",
|
||||
"defenseAt": "2026-11-08T00:00:00Z",
|
||||
"pointsMax": 10,
|
||||
"courseId": "$COURSE_ID",
|
||||
"courseNo": 1,
|
||||
"courseTitle": "Курс",
|
||||
"levels": [
|
||||
{
|
||||
"id": "level-1",
|
||||
"code": "L1",
|
||||
"title": "Уровень 1",
|
||||
"variants": [
|
||||
{ "id": "variant-1", "number": "1", "title": "Вариант 1.1", "statementMd": "Условие 1.1" },
|
||||
{ "id": "variant-2", "number": "2", "title": "Вариант 1.2", "statementMd": "Условие 1.2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "$LAB_TWO_ID",
|
||||
"number": 2,
|
||||
"title": "Лабораторная 2",
|
||||
"status": "PUBLISHED",
|
||||
"descriptionMd": "Описание 2",
|
||||
"pointsMax": 5,
|
||||
"courseId": "$COURSE_ID",
|
||||
"courseNo": 1,
|
||||
"courseTitle": "Курс",
|
||||
"levels": [
|
||||
{
|
||||
"id": "level-2",
|
||||
"code": "L2",
|
||||
"title": "Уровень 2",
|
||||
"variants": [
|
||||
{ "id": "$VARIANT_TWO_ID", "number": "1", "title": "Вариант 2.1", "statementMd": "Условие 2.1" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val LABS_WITHOUT_COURSE_JSON =
|
||||
"""
|
||||
[
|
||||
{ "id": "$LAB_ONE_ID", "number": 1, "title": "Лабораторная 1", "levels": [] },
|
||||
{ "id": "$LAB_TWO_ID", "number": 2, "title": "Лабораторная 2", "levels": [] }
|
||||
]
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user