feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости
- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток - V14: NOT NULL и полная уникальность когорты, preflight legacy-групп - возврат группы из архива и история членства на странице группы - пагинация групп и плоского списка материалов - пакетные сдачи ведомости одним запросом - XLSX-preflight импорта и audit-тесты ростера - LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
@@ -17,13 +17,15 @@ class GroupEntity(
|
||||
var id: UUID = Ids.v7(),
|
||||
@Column(nullable = false, length = 100)
|
||||
var name: String = "",
|
||||
@Column(length = 50)
|
||||
@Column(length = 50, nullable = false)
|
||||
var stream: String? = null,
|
||||
@Column(nullable = false)
|
||||
var course: Short? = null,
|
||||
@Column(nullable = false)
|
||||
var number: Short? = null,
|
||||
@Column(length = 2)
|
||||
@Column(length = 2, nullable = false)
|
||||
var degree: String? = null,
|
||||
@Column(name = "enrollment_year")
|
||||
@Column(name = "enrollment_year", nullable = false)
|
||||
var enrollmentYear: Short? = null,
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
|
||||
@@ -21,6 +21,8 @@ data class AccountInfoDto(
|
||||
val id: String,
|
||||
val login: String,
|
||||
val role: String,
|
||||
/** ФИО студента; для не-студенческих аккаунтов пусто. */
|
||||
val fullName: String = "",
|
||||
)
|
||||
|
||||
/** Группа с текущим составом — без учётных данных. */
|
||||
|
||||
@@ -76,15 +76,25 @@ class InternalService(
|
||||
)
|
||||
}
|
||||
|
||||
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
|
||||
/** Подписи авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
|
||||
@Transactional(readOnly = true)
|
||||
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
|
||||
val requested = ids.map(::parseId).distinct()
|
||||
if (requested.isEmpty()) return emptyList()
|
||||
val found = accounts.findAllById(requested).associateBy { it.id }
|
||||
val names =
|
||||
students
|
||||
.findAllById(requested)
|
||||
.mapNotNull { student -> student.accountId?.let { it to student.fullName } }
|
||||
.toMap()
|
||||
return requested.mapNotNull { id ->
|
||||
found[id]?.let { account ->
|
||||
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
|
||||
AccountInfoDto(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
fullName = names[account.id].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import java.util.UUID
|
||||
@@ -20,4 +21,6 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
|
||||
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
|
||||
|
||||
fun findAllByOrderByEnrollmentYearDescNameAsc(pageable: Pageable): List<GroupEntity>
|
||||
}
|
||||
|
||||
@@ -6,4 +6,9 @@ import java.util.UUID
|
||||
|
||||
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
|
||||
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
|
||||
|
||||
fun findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(
|
||||
toGroupId: UUID,
|
||||
fromGroupId: UUID,
|
||||
): List<MembershipHistory>
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ class GroupController(
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
fun list(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "0") limit: Int,
|
||||
): List<GroupSummary> = groupService.list(page, limit)
|
||||
|
||||
@PostMapping
|
||||
fun create(
|
||||
@@ -148,6 +151,29 @@ class GroupController(
|
||||
return detail
|
||||
}
|
||||
|
||||
/** Возврат группы из архива. */
|
||||
@PostMapping("/{id}/restore")
|
||||
fun restore(
|
||||
@PathVariable id: UUID,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupDetail {
|
||||
val detail = groupService.restoreGroup(id)
|
||||
auditService.record(
|
||||
jwt.toAuditRequest(
|
||||
action = "GROUP_RESTORE",
|
||||
entityType = "GROUP",
|
||||
entityId = id.toString(),
|
||||
details = "name=${detail.name}",
|
||||
),
|
||||
)
|
||||
return detail
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/history")
|
||||
fun history(
|
||||
@PathVariable id: UUID,
|
||||
): List<MembershipHistoryRow> = groupService.history(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@PathVariable id: UUID,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -34,8 +35,22 @@ class GroupService(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val cohortResolver: GroupCohortResolver,
|
||||
) {
|
||||
/** Список групп; [limit] = 0 — все записи (совместимость с существующим API). */
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
|
||||
fun list(
|
||||
page: Int = 0,
|
||||
limit: Int = 0,
|
||||
): List<GroupSummary> {
|
||||
val rows =
|
||||
if (limit <= 0) {
|
||||
groups.findAllByOrderByEnrollmentYearDescNameAsc()
|
||||
} else {
|
||||
groups.findAllByOrderByEnrollmentYearDescNameAsc(
|
||||
PageRequest.of(page.coerceAtLeast(0), limit.coerceIn(1, MAX_GROUP_PAGE)),
|
||||
)
|
||||
}
|
||||
return rows.map { summary(it) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createStudent(
|
||||
@@ -262,6 +277,43 @@ class GroupService(
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/** Последние события членства, связанные с группой (вход или выход). */
|
||||
@Transactional(readOnly = true)
|
||||
fun history(groupId: UUID): List<MembershipHistoryRow> {
|
||||
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val events = history.findTop200ByToGroupIdOrFromGroupIdOrderByAtDesc(groupId, groupId)
|
||||
val studentIds = events.map { it.studentId }.distinct()
|
||||
val studentsById = students.findAllById(studentIds).associateBy { it.accountId }
|
||||
val groupIds = events.flatMap { listOfNotNull(it.fromGroupId, it.toGroupId) }.distinct()
|
||||
val groupsById = groups.findAllById(groupIds).associateBy { it.id }
|
||||
return events.map { event ->
|
||||
MembershipHistoryRow(
|
||||
id = event.id.toString(),
|
||||
studentId = event.studentId.toString(),
|
||||
studentName = studentsById[event.studentId]?.fullName.orEmpty(),
|
||||
fromGroupId = event.fromGroupId?.toString(),
|
||||
fromGroupName = event.fromGroupId?.let { groupsById[it]?.name },
|
||||
toGroupId = event.toGroupId?.toString(),
|
||||
toGroupName = event.toGroupId?.let { groupsById[it]?.name },
|
||||
action = event.action.name,
|
||||
reason = event.reason,
|
||||
at = event.at.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Возвращает архивную группу в работу; состав и история не меняются. */
|
||||
@Transactional
|
||||
fun restoreGroup(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
if (group.status == GroupStatus.ACTIVE) {
|
||||
throw BadRequestException("Группа ${group.name} уже активна")
|
||||
}
|
||||
group.status = GroupStatus.ACTIVE
|
||||
groups.save(group)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет студента из identity: зачисление, профиль, аккаунт.
|
||||
* [confirm] должен совпадать с логином аккаунта или ФИО студента.
|
||||
@@ -350,6 +402,10 @@ class GroupService(
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_GROUP_PAGE = 500
|
||||
}
|
||||
|
||||
private fun activeGroup(groupId: UUID): GroupEntity {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
if (group.status != GroupStatus.ACTIVE) {
|
||||
|
||||
@@ -85,6 +85,20 @@ data class ResetPasswordResponse(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
/** Событие истории членства для страницы группы. */
|
||||
data class MembershipHistoryRow(
|
||||
val id: String,
|
||||
val studentId: String,
|
||||
val studentName: String,
|
||||
val fromGroupId: String?,
|
||||
val fromGroupName: String?,
|
||||
val toGroupId: String?,
|
||||
val toGroupName: String?,
|
||||
val action: String,
|
||||
val reason: String,
|
||||
val at: String,
|
||||
)
|
||||
|
||||
data class StudentCandidate(
|
||||
val id: String,
|
||||
val fullName: String,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Финальные ограничения когорты. Легаси-группы, которые не удалось разобрать
|
||||
-- из имени в V5, должны быть исправлены вручную до применения этой миграции:
|
||||
-- SELECT id, name, stream, degree, course, number, enrollment_year
|
||||
-- FROM identity.groups
|
||||
-- WHERE stream IS NULL OR degree IS NULL OR course IS NULL
|
||||
-- OR number IS NULL OR enrollment_year IS NULL;
|
||||
-- После UPDATE полей миграцию можно применить повторно.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM groups
|
||||
WHERE stream IS NULL
|
||||
OR degree IS NULL
|
||||
OR course IS NULL
|
||||
OR number IS NULL
|
||||
OR enrollment_year IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'Есть группы с незаполненной когортой; исправьте их перед V14 (см. комментарий в миграции)';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE groups
|
||||
ALTER COLUMN stream SET NOT NULL,
|
||||
ALTER COLUMN degree SET NOT NULL,
|
||||
ALTER COLUMN course SET NOT NULL,
|
||||
ALTER COLUMN number SET NOT NULL,
|
||||
ALTER COLUMN enrollment_year SET NOT NULL;
|
||||
|
||||
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_degree_check;
|
||||
ALTER TABLE groups ADD CONSTRAINT groups_degree_check CHECK (degree IN ('БО', 'МО'));
|
||||
|
||||
ALTER TABLE groups DROP CONSTRAINT IF EXISTS groups_course_range;
|
||||
ALTER TABLE groups ADD CONSTRAINT groups_course_range CHECK (
|
||||
(degree = 'БО' AND course BETWEEN 1 AND 4)
|
||||
OR (degree = 'МО' AND course BETWEEN 1 AND 2)
|
||||
);
|
||||
|
||||
-- Полная уникальность когорты: все поля уже NOT NULL.
|
||||
DROP INDEX IF EXISTS groups_cohort_unique;
|
||||
CREATE UNIQUE INDEX groups_cohort_unique
|
||||
ON groups (stream, degree, number, course, enrollment_year);
|
||||
@@ -8,7 +8,11 @@ import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
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.DynamicPropertyRegistry
|
||||
@@ -25,10 +29,13 @@ import org.springframework.web.context.WebApplicationContext
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
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.GroupController
|
||||
import ru.ysu.lms.identity.roster.GroupService
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@@ -63,6 +70,8 @@ class AuditIntegrationTest {
|
||||
|
||||
@Autowired lateinit var groupService: GroupService
|
||||
|
||||
@Autowired lateinit var groupController: GroupController
|
||||
|
||||
@Autowired lateinit var context: WebApplicationContext
|
||||
|
||||
private val objectMapper = ObjectMapper()
|
||||
@@ -378,6 +387,80 @@ class AuditIntegrationTest {
|
||||
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("LongMethod")
|
||||
fun `roster operations record group and student audit events`() {
|
||||
val actor =
|
||||
accounts
|
||||
.save(
|
||||
Account(
|
||||
login = "audit.admin.${UUID.randomUUID().toString().take(8)}",
|
||||
passwordHash = "x",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
),
|
||||
).id
|
||||
val jwt =
|
||||
Jwt
|
||||
.withTokenValue("admin-token")
|
||||
.header("alg", "none")
|
||||
.subject(actor.toString())
|
||||
.claim("login", "admin.audit")
|
||||
.build()
|
||||
val stream = "A" + UUID.randomUUID().toString().take(8)
|
||||
SecurityContextHolder.getContext().authentication =
|
||||
JwtAuthenticationToken(jwt, listOf(SimpleGrantedAuthority("ROLE_ADMIN")))
|
||||
try {
|
||||
val group =
|
||||
groupController.create(
|
||||
CreateGroupRequest(stream = stream, course = 1, number = 1, degree = "БО", enrollmentYear = 2025),
|
||||
jwt,
|
||||
)
|
||||
val credential =
|
||||
groupController.createStudent(
|
||||
UUID.fromString(group.id),
|
||||
CreateStudentRequest(studentNo = "9901", lastName = "Аудитов", firstName = "Иван"),
|
||||
jwt,
|
||||
)
|
||||
val target =
|
||||
groupController.create(
|
||||
CreateGroupRequest(stream = stream, course = 1, number = 2, degree = "БО", enrollmentYear = 2025),
|
||||
jwt,
|
||||
)
|
||||
groupController.enroll(UUID.fromString(target.id), UUID.fromString(credential.id), jwt)
|
||||
groupController.archive(UUID.fromString(target.id), jwt)
|
||||
groupController.restore(UUID.fromString(target.id), jwt)
|
||||
val csvBytes =
|
||||
(
|
||||
"поток;курс;номер;степень;год набора;ФИО;№;база\n" +
|
||||
"$stream;1;3;БО;2025;Импортов Пётр;9902;бюджет"
|
||||
).toByteArray(StandardCharsets.UTF_8)
|
||||
groupController.import(MockMultipartFile("file", "group.csv", "text/csv", csvBytes), false, jwt)
|
||||
|
||||
val events = auditEvents.findTop200ByOrderByOccurredAtDesc()
|
||||
assertTrue(
|
||||
events
|
||||
.map { it.action }
|
||||
.toSet()
|
||||
.containsAll(
|
||||
setOf(
|
||||
"GROUP_CREATE",
|
||||
"STUDENT_CREATE",
|
||||
"GROUP_ENROLL",
|
||||
"GROUP_IMPORT",
|
||||
"GROUP_ARCHIVE",
|
||||
"GROUP_RESTORE",
|
||||
),
|
||||
),
|
||||
)
|
||||
val created = events.first { it.action == "GROUP_CREATE" && it.details.contains(group.name) }
|
||||
assertEquals(actor, created.actorAccountId)
|
||||
assertEquals("admin.audit", created.actorLogin)
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext()
|
||||
}
|
||||
}
|
||||
|
||||
private data class AuditPage(
|
||||
val details: List<String>,
|
||||
val cursor: String?,
|
||||
|
||||
@@ -130,6 +130,24 @@ class InternalControllerIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lookup includes student full name and leaves staff name empty`() {
|
||||
val student = student(login = "a.ivanov", studentNo = "123456")
|
||||
val staff = account("t.teacher")
|
||||
|
||||
mockMvc
|
||||
.post("/api/v1/internal/accounts/lookup") {
|
||||
header("X-Internal-Token", TOKEN)
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = """{"ids":["${student.accountId}","${staff.id}"]}"""
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.length()") { value(2) }
|
||||
jsonPath("$[0].fullName") { value("Иванов Иван Иванович") }
|
||||
jsonPath("$[1].fullName") { value("") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lookup ignores missing accounts and duplicates`() {
|
||||
val existing = account("a.ivanov")
|
||||
@@ -275,8 +293,29 @@ class InternalControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `valid token returns groups sorted by name with status`() {
|
||||
val graduated = groups.save(GroupEntity(name = "ПИ-22БО", enrollmentYear = 2025, status = GroupStatus.GRADUATED))
|
||||
val active = groups.save(GroupEntity(name = "ПИ-21БО", enrollmentYear = 2025))
|
||||
val graduated =
|
||||
groups.save(
|
||||
GroupEntity(
|
||||
name = "ПИ-22БО",
|
||||
stream = "ПИ",
|
||||
course = 2,
|
||||
number = 2,
|
||||
degree = "БО",
|
||||
enrollmentYear = 2025,
|
||||
status = GroupStatus.GRADUATED,
|
||||
),
|
||||
)
|
||||
val active =
|
||||
groups.save(
|
||||
GroupEntity(
|
||||
name = "ПИ-21БО",
|
||||
stream = "ПИ",
|
||||
course = 2,
|
||||
number = 1,
|
||||
degree = "БО",
|
||||
enrollmentYear = 2025,
|
||||
),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.get("/api/v1/internal/groups") {
|
||||
@@ -338,7 +377,19 @@ class InternalControllerIntegrationTest {
|
||||
),
|
||||
)
|
||||
|
||||
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
|
||||
private fun group(name: String): GroupEntity {
|
||||
val match = Regex("""-(\d)(\d+)(БО|МО)$""").find(name)
|
||||
return groups.save(
|
||||
GroupEntity(
|
||||
name = name,
|
||||
stream = "ПИ",
|
||||
course = match?.groupValues?.get(1)?.toShortOrNull() ?: 1,
|
||||
number = match?.groupValues?.get(2)?.toShortOrNull() ?: 1,
|
||||
degree = match?.groupValues?.get(3) ?: "БО",
|
||||
enrollmentYear = 2025,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun enroll(
|
||||
group: GroupEntity,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
@@ -13,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
@@ -26,6 +28,7 @@ import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.MembershipHistoryRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
@SpringBootTest
|
||||
@@ -89,6 +92,40 @@ class GroupServiceIntegrationTest {
|
||||
assertTrue(error is ConflictException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates two distinct cohorts in the same stream`() {
|
||||
val first = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
|
||||
val second = groupService.create(CreateGroupRequest(stream = "ТЕСТ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
|
||||
|
||||
assertEquals("ТЕСТ-11БО", first.name)
|
||||
assertEquals("ТЕСТ-12БО", second.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lists groups page by page and returns all without limit`() {
|
||||
val stream = "P" + UUID.randomUUID().toString().take(8)
|
||||
repeat(3) { index ->
|
||||
groupService.create(
|
||||
CreateGroupRequest(
|
||||
stream = stream,
|
||||
course = 1,
|
||||
number = (index + 1).toShort(),
|
||||
degree = "БО",
|
||||
enrollmentYear = 2025,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val first = groupService.list(page = 0, limit = 2).filter { it.name.startsWith(stream) }
|
||||
val second = groupService.list(page = 1, limit = 2).filter { it.name.startsWith(stream) }
|
||||
val all = groupService.list().filter { it.name.startsWith(stream) }
|
||||
|
||||
assertEquals(2, first.size)
|
||||
assertEquals(1, second.size)
|
||||
assertEquals(3, all.size)
|
||||
assertTrue(first.intersect(second.toSet()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `imports students and records membership history`() {
|
||||
val result = importService.import("group.csv", csv(validRows()), null)
|
||||
@@ -106,6 +143,11 @@ class GroupServiceIntegrationTest {
|
||||
assertEquals(2025.toShort(), student.enrollmentYear)
|
||||
val events = history.findByStudentIdOrderByAtDesc(studentId)
|
||||
assertEquals(MembershipAction.IMPORT, events.first().action)
|
||||
val groupHistory = groupService.history(groupId)
|
||||
val imported = groupHistory.first { it.studentId == studentId.toString() }
|
||||
assertEquals("IMPORT", imported.action)
|
||||
assertEquals("Иванов Иван", imported.studentName)
|
||||
assertEquals("ПИ-21БО", imported.toGroupName)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -272,6 +314,64 @@ class GroupServiceIntegrationTest {
|
||||
assertNull(students.findByStudentNo("3001"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restores archived group and rejects repeated restore`() {
|
||||
val created = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 7, degree = "БО", enrollmentYear = 2026))
|
||||
val id = UUID.fromString(created.id)
|
||||
|
||||
assertEquals("ARCHIVED", groupService.archiveGroup(id).status)
|
||||
assertEquals("ACTIVE", groupService.restoreGroup(id).status)
|
||||
|
||||
val error = runCatching { groupService.restoreGroup(id) }.exceptionOrNull()
|
||||
assertTrue(error is BadRequestException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xlsx import reports occupied login before writing`() {
|
||||
val studentNo = "X" + UUID.randomUUID().toString().take(8)
|
||||
accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = "x",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
),
|
||||
)
|
||||
|
||||
val result = importService.import("group.xlsx", xlsx("ПИ;1;4;БО;2026;Иванов Иван;$studentNo;бюджет"), null)
|
||||
|
||||
assertFalse(result.applied)
|
||||
assertEquals(1, result.errors.size)
|
||||
assertEquals(2, result.errors.single().row)
|
||||
assertTrue(
|
||||
result.errors
|
||||
.single()
|
||||
.message
|
||||
.contains("Логин $studentNo"),
|
||||
)
|
||||
assertNull(students.findByStudentNo(studentNo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xlsx import reports inactive target group before writing`() {
|
||||
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 5, degree = "БО", enrollmentYear = 2026))
|
||||
groupService.archiveGroup(UUID.fromString(group.id))
|
||||
val studentNo = "Y" + UUID.randomUUID().toString().take(8)
|
||||
|
||||
val result = importService.import("group.xlsx", xlsx("ПИ;1;5;БО;2026;Петров Пётр;$studentNo;бюджет"), null)
|
||||
|
||||
assertFalse(result.applied)
|
||||
assertEquals(1, result.errors.size)
|
||||
assertEquals(2, result.errors.single().row)
|
||||
assertTrue(
|
||||
result.errors
|
||||
.single()
|
||||
.message
|
||||
.contains("не активна"),
|
||||
)
|
||||
assertNull(students.findByStudentNo(studentNo))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import reports inactive target group and writes nothing`() {
|
||||
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
|
||||
@@ -324,4 +424,22 @@ class GroupServiceIntegrationTest {
|
||||
""".trimIndent()
|
||||
|
||||
private fun csv(rows: String): ByteArray = ("поток;курс;номер;степень;год набора;ФИО;№;база\n" + rows).toByteArray()
|
||||
|
||||
private fun xlsx(row: String): ByteArray {
|
||||
val headers = listOf("поток", "курс", "номер", "степень", "год набора", "ФИО", "№", "база")
|
||||
val values = row.split(";")
|
||||
return XSSFWorkbook().use { workbook ->
|
||||
val sheet = workbook.createSheet("group")
|
||||
sheet.createRow(0).apply {
|
||||
headers.forEachIndexed { index, header -> createCell(index).setCellValue(header) }
|
||||
}
|
||||
sheet.createRow(1).apply {
|
||||
values.forEachIndexed { index, value -> createCell(index).setCellValue(value) }
|
||||
}
|
||||
ByteArrayOutputStream().use { out ->
|
||||
workbook.write(out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user