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