diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt index 0981e48..27d089c 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -187,6 +187,17 @@ class CourseController( return course } + /** Активные группы курса с именами из identity — одним запросом. */ + @GetMapping("/{id}/groups") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") + fun attachedGroups( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): List { + accessService.requireCourseAccess(id, jwt) + return courseGroupService.attachedGroups(id) + } + /** Активные группы identity, ещё не привязанные к курсу. */ @GetMapping("/{id}/available-groups") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt index 8e52bb8..5ae1898 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseDtos.kt @@ -52,7 +52,7 @@ data class CourseGroupRequest( val groupId: String, ) -/** Группа, которую можно привязать к курсу: ещё не привязана и активна в identity. */ +/** Группа курса с именем и статусом из identity: привязанная или доступная для привязки. */ data class CourseGroupOption( val id: String, val name: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt index 8f67e53..cd12b90 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseGroupService.kt @@ -31,6 +31,22 @@ class CourseGroupService( .map { CourseGroupOption(id = it.id, name = it.name, status = it.status) } } + /** + * Активно привязанные к курсу группы с именами из identity. Один вызов вместо + * запроса имени на каждую привязку. Статус не фильтруется: если группа стала + * неактивной, она остаётся видимой в привязках курса. + */ + @Transactional(readOnly = true) + fun attachedGroups(courseId: UUID): List { + if (!courses.existsById(courseId)) throw NotFoundException("Курс не найден") + val attachedIds = groups.findByCourseIdAndRemovedAtIsNull(courseId).mapTo(HashSet()) { it.groupId.toString() } + return identity + .listGroups() + .filter { it.id in attachedIds } + .sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) + .map { CourseGroupOption(id = it.id, name = it.name, status = it.status) } + } + /** Привязывать можно только существующую активную группу identity. */ @Transactional(readOnly = true) fun requireAttachable(groupIdValue: String) { diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt index 1fa8397..eeeeec8 100644 --- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseGroupTeacherAccessTest.kt @@ -1,6 +1,8 @@ package ru.ysu.lms.academic.course import org.junit.jupiter.api.Test +import org.mockito.Mockito.times +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -108,6 +110,40 @@ class CourseGroupTeacherAccessTest { .andExpect(jsonPath("$.groups[0].removedAt").exists()) } + @Test + fun `teacher gets attached groups with names in one call`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + val alpha = UUID.randomUUID() + val graduated = UUID.randomUUID() + val free = UUID.randomUUID() + courseService.attachGroup(courseId, CourseGroupRequest(alpha.toString())) + courseService.attachGroup(courseId, CourseGroupRequest(graduated.toString())) + `when`(identity.listGroups()).thenReturn( + listOf( + IdentityGroupSummary(graduated.toString(), "Бета", "GRADUATED"), + IdentityGroupSummary(free.toString(), "Свободна", "ACTIVE"), + IdentityGroupSummary(alpha.toString(), "Альфа", "ACTIVE"), + ), + ) + + mockMvc + .perform( + get("/api/v1/courses/{id}/groups", courseId) + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(alpha.toString())) + .andExpect(jsonPath("$[0].name").value("Альфа")) + .andExpect(jsonPath("$[0].status").value("ACTIVE")) + .andExpect(jsonPath("$[1].id").value(graduated.toString())) + .andExpect(jsonPath("$[1].name").value("Бета")) + .andExpect(jsonPath("$[1].status").value("GRADUATED")) + + verify(identity, times(1)).listGroups() + } + @Test fun `foreign teacher cannot list attach or detach groups`() { val courseId = newCourse() @@ -119,6 +155,10 @@ class CourseGroupTeacherAccessTest { .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(foreign)) .andExpect(status().isForbidden) + mockMvc + .perform(get("/api/v1/courses/{id}/groups", courseId).with(foreign)) + .andExpect(status().isForbidden) + mockMvc .perform( post("/api/v1/courses/{id}/groups", courseId) @@ -143,6 +183,10 @@ class CourseGroupTeacherAccessTest { .perform(get("/api/v1/courses/{id}/available-groups", courseId).with(student)) .andExpect(status().isForbidden) + mockMvc + .perform(get("/api/v1/courses/{id}/groups", courseId).with(student)) + .andExpect(status().isForbidden) + mockMvc .perform( post("/api/v1/courses/{id}/groups", courseId) @@ -177,6 +221,13 @@ class CourseGroupTeacherAccessTest { ).andExpect(status().isOk) .andExpect(jsonPath("$.groups[0].groupId").value(groupId.toString())) + mockMvc + .perform(get("/api/v1/courses/{id}/groups", courseId).with(admin)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value(groupId.toString())) + .andExpect(jsonPath("$[0].name").value("Альфа")) + mockMvc .perform(delete("/api/v1/courses/{id}/groups/{groupId}", courseId, groupId).with(admin)) .andExpect(status().isOk) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt index 3be348a..fe2b610 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt @@ -17,5 +17,6 @@ class AuditController( fun list( @RequestParam(defaultValue = "100") limit: Int, @RequestParam(required = false) action: String?, - ): List = auditService.list(limit, action) + @RequestParam(defaultValue = "0") page: Int, + ): List = auditService.list(limit, action, page) } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt index b9ea6b7..d2c42a6 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt @@ -38,15 +38,17 @@ class AuditService( fun list( limit: Int = DEFAULT_LIMIT, action: String? = null, + page: Int = 0, ): List { val size = limit.coerceIn(1, MAX_LIMIT) - val page = PageRequest.of(0, size) + 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(page) + events.findAllByOrderByOccurredAtDescIdDesc(pageable) } else { - events.findAllByActionOrderByOccurredAtDescIdDesc(filter, page) + events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable) } return found.map(::toDto) } diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt index a063630..5beabfa 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt @@ -29,6 +29,7 @@ import ru.ysu.lms.identity.repo.AccountRepository import ru.ysu.lms.identity.roster.CreateGroupRequest import ru.ysu.lms.identity.roster.CreateStudentRequest import ru.ysu.lms.identity.roster.GroupService +import java.nio.charset.StandardCharsets import java.util.UUID @SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"]) @@ -198,6 +199,33 @@ class AuditIntegrationTest { } } + @Test + fun `pagination returns disjoint pages newest first`() { + repeat(5) { index -> + write(AuditEventRequest(action = "AUDIT_PAGE", entityType = "STUDENT", details = "page-${index + 1}")) + } + + val first = detailsOfPage(limit = 2, page = 0) + val second = detailsOfPage(limit = 2, page = 1) + val third = detailsOfPage(limit = 2, page = 2) + + assertEquals(listOf("page-5", "page-4"), first) + assertEquals(listOf("page-3", "page-2"), second) + assertEquals(listOf("page-1"), third) + assertTrue(first.intersect(second.toSet()).isEmpty()) + assertTrue((first + second).intersect(third.toSet()).isEmpty()) + } + + @Test + fun `negative page is clamped to the first page and page past the end is empty`() { + repeat(3) { index -> + write(AuditEventRequest(action = "AUDIT_PAGE_CLAMP", entityType = "STUDENT", details = "page-${index + 1}")) + } + + assertEquals(listOf("page-3", "page-2"), detailsOfPage(limit = 2, page = -5)) + assertEquals(emptyList(), detailsOfPage(limit = 2, page = 1000)) + } + @Test fun `blank action or entity type is rejected`() { writeExpectingBadRequest(AuditEventRequest(action = " ", entityType = "STUDENT")) @@ -291,6 +319,24 @@ class AuditIntegrationTest { assertTrue(auditEvents.findById(deleteEvent.id).isPresent) } + private fun detailsOfPage( + limit: Int, + page: Int, + ): List { + val body = + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("limit", limit.toString()) + param("page", page.toString()) + }.andExpect { + status { isOk() } + }.andReturn() + .response + .getContentAsString(StandardCharsets.UTF_8) + return objectMapper.readTree(body).map { it.path("details").asText() } + } + private fun write(request: AuditEventRequest) { mockMvc .post("/api/v1/internal/audit") { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt index c6ef4d3..c5eb69b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt @@ -24,14 +24,16 @@ class AdminAuditController( fun list( @RequestParam(required = false) action: String?, @RequestParam(required = false) limit: String?, + @RequestParam(required = false) page: String?, session: HttpSession, model: Model, ): String { val filter = action?.trim()?.takeIf { it.isNotEmpty() } val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT) + val pageNumber = (page?.trim()?.toIntOrNull() ?: 0).coerceAtLeast(0) val events: List = try { - api.listAudit(token(session), pageSize, filter) + api.listAudit(token(session), pageSize, filter, pageNumber) } catch (ex: ApiException) { model.addAttribute("error", ex.message) emptyList() @@ -39,6 +41,9 @@ class AdminAuditController( model.addAttribute("events", AuditViews.rows(events)) model.addAttribute("action", filter.orEmpty()) model.addAttribute("limit", pageSize) + model.addAttribute("page", pageNumber) + model.addAttribute("hasNext", events.size == pageSize) + model.addAttribute("hasPrev", pageNumber > 0) model.addAttribute("actionOptions", AuditViews.actionOptions(filter)) return "admin/audit" } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt index ff72315..0a44a32 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt @@ -7,6 +7,7 @@ fun ApiClient.listAudit( token: String, limit: Int = 100, action: String? = null, + page: Int = 0, ): List { val type = object : ParameterizedTypeReference>() {} return call { @@ -16,6 +17,7 @@ fun ApiClient.listAudit( builder.path("/api/v1/audit") builder.queryParam("limit", limit) if (!action.isNullOrBlank()) builder.queryParam("action", action) + builder.queryParam("page", page.coerceAtLeast(0)) builder.build() }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt index 95ea163..a435dbc 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientCourse.kt @@ -87,6 +87,22 @@ fun ApiClient.courseAvailableGroups( } } +/** Активные группы, привязанные к курсу, с названиями — одним запросом. */ +fun ApiClient.courseGroups( + token: String, + courseId: String, +): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/courses/{id}/groups", courseId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + /** Состав группы в рамках курса: доступен преподавателю курса и администратору. */ fun ApiClient.courseGroupStudents( token: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt index 7b59b65..175fee8 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/teacher/TeacherController.kt @@ -17,6 +17,7 @@ import ru.ysu.lms.web.api.attachCourseGroup import ru.ysu.lms.web.api.courseAvailableGroups import ru.ysu.lms.web.api.courseDetail import ru.ysu.lms.web.api.courseGroupStudents +import ru.ysu.lms.web.api.courseGroups import ru.ysu.lms.web.api.detachCourseGroup import ru.ysu.lms.web.api.listLabs import ru.ysu.lms.web.api.myCourses @@ -57,9 +58,11 @@ class TeacherController( val token = token(session) ?: return "redirect:/login" val course = api.courseDetail(token, id) val groups = course.groups.filter { it.removedAt == null } + // Названия привязанных групп приходят одним запросом; при его недоступности шаблон покажет id. + val attachedGroups = runCatching { api.courseGroups(token, id) }.getOrDefault(emptyList()) model.addAttribute("course", course) model.addAttribute("groups", groups) - model.addAttribute("groupNames", groupNames(token, id, groups.map { it.groupId })) + model.addAttribute("groupNames", attachedGroups.associate { it.id to it.name.ifBlank { it.id } }) model.addAttribute("availableGroups", runCatching { api.courseAvailableGroups(token, id) }.getOrDefault(emptyList())) model.addAttribute( "labs", @@ -130,18 +133,5 @@ class TeacherController( return "teacher/group" } - /** Названия привязанных групп; при недоступности состава показываем id. */ - private fun groupNames( - token: String, - courseId: String, - groupIds: List, - ): Map = - groupIds.associate { groupId -> - groupId to - runCatching { api.courseGroupStudents(token, courseId, groupId).groupName } - .getOrDefault("") - .ifBlank { groupId } - } - private fun token(session: HttpSession): String? = session.getAttribute(SessionKeys.TOKEN) as? String } diff --git a/web/src/main/resources/templates/admin/audit.html b/web/src/main/resources/templates/admin/audit.html index 14eb9fb..f321323 100644 --- a/web/src/main/resources/templates/admin/audit.html +++ b/web/src/main/resources/templates/admin/audit.html @@ -64,6 +64,18 @@ + + diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt index 3c999a2..90c0337 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt @@ -27,41 +27,16 @@ class AdminAuditTemplateTest { @Test fun `renders audit events with labels and filter`() { - val rows = - listOf( - AuditRow( - occurredMsk = "01.09.2026 00:00", - actorLabel = "admin", - action = "GRADE_SET", - actionLabel = "Выставлена оценка", - entityType = "GRADE", - entityId = "g1", - entityLabel = "Оценка · g1", - courseId = "c1", - studentId = "s1", - details = "Оценка 5", - ), - AuditRow( - occurredMsk = "31.08.2026 23:50", - actorLabel = "система", - action = "STUDENT_TRANSFER", - actionLabel = "Перевод студента", - entityType = "STUDENT", - entityId = null, - entityLabel = "Студент", - courseId = null, - studentId = null, - details = "", - ), - ) - val html = render( "admin/audit", mapOf( - "events" to rows, + "events" to auditRows(), "action" to "GRADE_SET", "limit" to 100, + "page" to 1, + "hasNext" to true, + "hasPrev" to true, "actionOptions" to AuditViews.actionOptions("GRADE_SET"), ), ) @@ -81,6 +56,13 @@ class AdminAuditTemplateTest { assertThat(html).contains("c1") assertThat(html).contains("s1") assertThat(html).contains("Оценка 5") + assertThat(html).contains("Страница 2") + assertThat(html).contains("Назад") + assertThat(html).contains("Показать ещё") + assertThat(html).contains("action=GRADE_SET") + assertThat(html).contains("limit=100") + assertThat(html).contains("page=0") + assertThat(html).contains("page=2") } @Test @@ -92,12 +74,18 @@ class AdminAuditTemplateTest { "events" to emptyList(), "action" to "", "limit" to 100, + "page" to 0, + "hasNext" to false, + "hasPrev" to false, "actionOptions" to AuditViews.actionOptions(null), ), ) assertThat(html).contains("Аудит") assertThat(html).contains("Записей аудита нет.") + assertThat(html).contains("Страница 1") + assertThat(html).doesNotContain(">Назад") + assertThat(html).doesNotContain(">Показать ещё") } @Test @@ -109,14 +97,47 @@ class AdminAuditTemplateTest { "events" to emptyList(), "action" to "CUSTOM_ACTION", "limit" to 100, + "page" to 2, + "hasNext" to true, + "hasPrev" to true, "actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"), ), ) assertThat(html).contains("value=\"CUSTOM_ACTION\"") assertThat(html).contains("CUSTOM_ACTION") + assertThat(html).contains("page=1") + assertThat(html).contains("page=3") } + private fun auditRows(): List = + listOf( + AuditRow( + occurredMsk = "01.09.2026 00:00", + actorLabel = "admin", + action = "GRADE_SET", + actionLabel = "Выставлена оценка", + entityType = "GRADE", + entityId = "g1", + entityLabel = "Оценка · g1", + courseId = "c1", + studentId = "s1", + details = "Оценка 5", + ), + AuditRow( + occurredMsk = "31.08.2026 23:50", + actorLabel = "система", + action = "STUDENT_TRANSFER", + actionLabel = "Перевод студента", + entityType = "STUDENT", + entityId = null, + entityLabel = "Студент", + courseId = null, + studentId = null, + details = "", + ), + ) + private fun render( template: String, variables: Map, diff --git a/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt new file mode 100644 index 0000000..cf0b792 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt @@ -0,0 +1,87 @@ +package ru.ysu.lms.web.api + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import tools.jackson.databind.ObjectMapper +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicReference + +class ApiClientAuditTest { + @Test + fun `listAudit sends pagination and filter query params`() { + val query = AtomicReference() + val authorization = AtomicReference() + withAuditServer(query, authorization) { baseUrl -> + val events = ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", 50, "GRADE_SET", 3) + + assertThat(events).hasSize(1) + assertThat(events[0].id).isEqualTo("e1") + assertThat(authorization.get()).isEqualTo("Bearer session-token") + assertThat(query.get()).contains("limit=50") + assertThat(query.get()).contains("action=GRADE_SET") + assertThat(query.get()).contains("page=3") + } + } + + @Test + fun `listAudit defaults to first page without filter`() { + val query = AtomicReference() + val authorization = AtomicReference() + withAuditServer(query, authorization) { baseUrl -> + ApiClient(baseUrl, ObjectMapper()).listAudit("session-token") + + assertThat(query.get()).contains("limit=100") + assertThat(query.get()).contains("page=0") + assertThat(query.get()).doesNotContain("action=") + } + } + + private fun withAuditServer( + query: AtomicReference, + authorization: AtomicReference, + block: (String) -> Unit, + ) { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/api/v1/audit") { exchange -> + query.set(exchange.requestURI.rawQuery) + authorization.set(exchange.requestHeaders.getFirst("Authorization")) + respond( + exchange, + """ + [ + { + "id": "e1", + "occurredAt": "2026-08-31T21:00:00Z", + "actorAccountId": "a1", + "actorLogin": "admin", + "action": "GRADE_SET", + "entityType": "GRADE", + "entityId": "g1", + "courseId": "c1", + "studentId": "s1", + "details": "Оценка 5" + } + ] + """.trimIndent(), + ) + } + server.start() + try { + block("http://127.0.0.1:${server.address.port}") + } finally { + server.stop(0) + } + } + + private fun respond( + exchange: HttpExchange, + body: String, + ) { + val bytes = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientCourseGroupsTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientCourseGroupsTest.kt new file mode 100644 index 0000000..7084f8b --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientCourseGroupsTest.kt @@ -0,0 +1,55 @@ +package ru.ysu.lms.web.api + +import com.sun.net.httpserver.HttpServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import tools.jackson.databind.ObjectMapper +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicReference + +class ApiClientCourseGroupsTest { + @Test + fun `courseGroups loads attached groups in one authorized call`() { + val path = AtomicReference() + val authorization = AtomicReference() + withGroupsServer(path, authorization) { baseUrl -> + val groups = ApiClient(baseUrl, ObjectMapper()).courseGroups("session-token", "c1") + + assertThat(path.get()).isEqualTo("/api/v1/courses/c1/groups") + assertThat(authorization.get()).isEqualTo("Bearer session-token") + assertThat(groups).hasSize(2) + assertThat(groups[0].id).isEqualTo("g1") + assertThat(groups[0].name).isEqualTo("ПИ-21БО") + assertThat(groups[1].id).isEqualTo("g2") + assertThat(groups[1].name).isEqualTo("ПИ-22БО") + } + } + + private fun withGroupsServer( + path: AtomicReference, + authorization: AtomicReference, + block: (String) -> Unit, + ) { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/api/v1/courses/c1/groups") { exchange -> + path.set(exchange.requestURI.path) + authorization.set(exchange.requestHeaders.getFirst("Authorization")) + val body = + """ + [ + {"id": "g1", "name": "ПИ-21БО", "status": "ACTIVE"}, + {"id": "g2", "name": "ПИ-22БО", "status": "ACTIVE"} + ] + """.trimIndent().toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + server.start() + try { + block("http://127.0.0.1:${server.address.port}") + } finally { + server.stop(0) + } + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/teacher/TeacherControllerCourseGroupsTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/teacher/TeacherControllerCourseGroupsTest.kt new file mode 100644 index 0000000..a5b4f86 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/teacher/TeacherControllerCourseGroupsTest.kt @@ -0,0 +1,108 @@ +package ru.ysu.lms.web.teacher + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpSession +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.CourseDetailDto +import ru.ysu.lms.web.api.CourseGroupDto +import ru.ysu.lms.web.api.CourseGroupOptionDto +import ru.ysu.lms.web.api.LabSummaryDto +import ru.ysu.lms.web.api.courseAvailableGroups +import ru.ysu.lms.web.api.courseDetail +import ru.ysu.lms.web.api.courseGroupStudents +import ru.ysu.lms.web.api.courseGroups +import ru.ysu.lms.web.api.listLabs +import ru.ysu.lms.web.session.SessionKeys +import ru.ysu.lms.web.session.SessionUser + +class TeacherControllerCourseGroupsTest { + private val api = mockk() + + private val mockMvc: MockMvc = + MockMvcBuilders + .standaloneSetup(TeacherController(api)) + .build() + + @BeforeEach + fun setUp() { + mockkStatic("ru.ysu.lms.web.api.ApiClientCourseKt", "ru.ysu.lms.web.api.ApiClientLabKt") + every { api.courseDetail("token", "c1") } returns + CourseDetailDto( + id = "c1", + courseNo = 1, + title = "Программирование", + groups = + listOf( + CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z"), + CourseGroupDto(groupId = "g2", addedAt = "2026-01-02T00:00:00Z", removedAt = "2026-02-01T00:00:00Z"), + ), + ) + every { api.listLabs("token", "c1") } returns emptyList() + every { api.courseAvailableGroups("token", "c1") } returns emptyList() + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `loads group names with a single call instead of per-group requests`() { + every { api.courseGroups("token", "c1") } returns + listOf( + CourseGroupOptionDto(id = "g1", name = "ПИ-21БО", status = "ACTIVE"), + CourseGroupOptionDto(id = "g2", name = "ПИ-22БО", status = "ACTIVE"), + ) + + val model = renderModel() + + assertThat(model["groupNames"]).isEqualTo(mapOf("g1" to "ПИ-21БО", "g2" to "ПИ-22БО")) + assertThat(model["groups"]).isEqualTo(listOf(CourseGroupDto(groupId = "g1", addedAt = "2026-01-01T00:00:00Z"))) + verify(exactly = 1) { api.courseGroups("token", "c1") } + verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) } + } + + @Test + fun `falls back to empty names when groups endpoint is unavailable`() { + every { api.courseGroups("token", "c1") } throws ApiException(502, "Сервис недоступен") + + val model = renderModel() + + assertThat(model["groupNames"]).isEqualTo(emptyMap()) + verify(exactly = 0) { api.courseGroupStudents(any(), any(), any()) } + } + + private fun renderModel() = + mockMvc + .perform(get("/teacher/courses/c1").session(session())) + .andReturn() + .modelAndView!! + .model + + private fun session(): MockHttpSession = + MockHttpSession().apply { + setAttribute(SessionKeys.TOKEN, "token") + setAttribute( + SessionKeys.USER, + SessionUser( + id = "t1", + login = "teacher", + role = "TEACHER", + name = "Преподаватель", + mustChangePassword = false, + ), + ) + } +}