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

@@ -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
}
}
}

View File

@@ -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()
}
}