fix(import): построчные ошибки до записи

- preflight занятого логина и неактивной целевой группы
- applied=false с номером строки, без частичных студентов
- LDD-IMPORT-003; baseline 58 claims / 368 тестов
This commit is contained in:
2026-10-29 19:20:16 +03:00
parent 238c689175
commit 1570196cc7
11 changed files with 142 additions and 30 deletions

View File

@@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21,
Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка —
[`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md).
Baseline: **57 claims, 366 тестов, 18/18 browser E2E**. Baseline: **58 claims, 368 тестов, 18/18 browser E2E**.
## Запуск в Docker (для сервера) ## Запуск в Docker (для сервера)

View File

@@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13.
- **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные,
ведомость, группы, материалы), центральный аудит `identity.audit_events` ведомость, группы, материалы), центральный аудит `identity.audit_events`
с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон
тестов/линтеров (**366**, 0 failures), upgrade миграций с master-схемы тестов/линтеров (**368**, 0 failures), upgrade миграций с master-схемы
(identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица, (identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица,
gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims gateway, видимость активов и deploy-runbook. LDD baseline: **58 claims
(27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: (27 P0 / 31 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт:
[16-stage6-report.md](16-stage6-report.md). [16-stage6-report.md](16-stage6-report.md).
### 9.1. Известные пробелы после этапа 6 ### 9.1. Известные пробелы после этапа 6

View File

@@ -65,15 +65,15 @@
| Модуль | Тестов | Failures | Errors | Skipped | | Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:| |---|---:|---:|---:|---:|
| identity-service | 103 | 0 | 0 | 0 | | identity-service | 105 | 0 | 0 | 0 |
| academic-service | 106 | 0 | 0 | 0 | | academic-service | 106 | 0 | 0 | 0 |
| progress-service | 38 | 0 | 0 | 0 | | progress-service | 38 | 0 | 0 | 0 |
| web | 112 | 0 | 0 | 0 | | web | 112 | 0 | 0 | 0 |
| gateway | 2 | 0 | 0 | 0 | | gateway | 2 | 0 | 0 | 0 |
| markdown | 5 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 |
| **Итого на момент этапа 6** | **366** | **0** | **0** | **0** | | **Итого на момент этапа 6** | **368** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **366 тестов, 0 failures** После закрытия рисков актуальный LDD-baseline — **368 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация (см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже. gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на
@@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией
### 2.3. Security-проверки ### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures) Проверено автоматическими тестами (актуальный прогон — 368 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс». negative-тестами «свой/чужой курс».
@@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key
## 7. LDD baseline ## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**57 claims, 27 P0 / 30 P1**), LDD-контур: реестр `docs/evidence/claims.json` (**58 claims, 27 P0 / 31 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow, gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow,
mutation testing и браузерный Playwright E2E. Результат: mutation testing и браузерный Playwright E2E. Результат:
**57/57 claims proven, P0 gaps = 0, 366 тестов, 0 failures**; mutation strength **58/58 claims proven, P0 gaps = 0, 368 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit); `CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit);
конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп /
1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные.

View File

@@ -116,8 +116,8 @@
### 1.8. LDD и проверки ### 1.8. LDD и проверки
- Реестр: `docs/evidence/claims.json`. - Реестр: `docs/evidence/claims.json`.
- **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**. - **58 claims**: 27 P0 + 31 P1; **58/58 proven, P0 gaps = 0**.
- **366 автотестов**, 0 failures/errors/skipped. - **368 автотестов**, 0 failures/errors/skipped.
- Mutation testing: - Mutation testing:
- `CourseAccessService` — 75% test strength; - `CourseAccessService` — 75% test strength;
- `PromotionPlanner` — 69%. - `PromotionPlanner` — 69%.
@@ -194,8 +194,8 @@ cat build/reports/evidence/index.md
``` ```
Последний подтверждённый результат: Последний подтверждённый результат:
- `366` тестов, 0 failures; - `368` тестов, 0 failures;
- `57/57` claims, P0 gaps = 0; - `58/58` claims, P0 gaps = 0;
- `ldd-release.sh` — PASS; - `ldd-release.sh` — PASS;
- smoke — PASS; - smoke — PASS;
- browser E2E — 18/18 (Chromium/Firefox/WebKit); - browser E2E — 18/18 (Chromium/Firefox/WebKit);
@@ -275,8 +275,8 @@ cat build/reports/evidence/index.md
`feature/groups-courses`. `feature/groups-courses`.
2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и
`docs/evidence/baseline-2026-10-27.md`. `docs/evidence/baseline-2026-10-27.md`.
3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов 3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 368 тестов
и 57 claims, 57/57 proven. и 58 claims, 58/58 proven.
4. Для release-проверки: `./scripts/ldd-release.sh`. 4. Для release-проверки: `./scripts/ldd-release.sh`.
5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`.
6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и

View File

@@ -26,7 +26,7 @@
| Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение | | Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение |
| CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён | | CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён |
| Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе | | Производительность | partial | Keyset-аудит, пакетные лабы и группы курса есть; списки материалов/групп без пагинации; сдачи в ведомости по выбранной лабе |
| LDD | implemented | 57 claims (27 P0 / 30 P1), 57/57 proven после release; `check` доказывает P0/unit, runtime P1 — release | | LDD | implemented | 58 claims (27 P0 / 31 P1), 58/58 proven после release; `check` доказывает P0/unit, runtime P1 — release |
| Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена | | Legacy-активы | partial | Код и UI готовы; инвентаризация на production/staging не выполнена |
| «Контингент» | out of scope | Исключён решением 2026-10-28 | | «Контингент» | out of scope | Исключён решением 2026-10-28 |
@@ -83,8 +83,9 @@
### P1 ### P1
- **Импорт: ошибки БД.** Ошибки lookup/конфликта всё ещё могут выйти одним - **Импорт: ошибки БД.** Занятый логин и неактивная группа теперь дают
flash-сообщением без номера строки; нужен row-level отчёт и для них. row-level `applied=false`; неожиданные DB-ошибки вне preflight всё ещё
откатывают транзакцию без построчного отчёта.
- **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null` - **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null`
и не создаёт план за предыдущий год; после долгого простоя в январе и не создаёт план за предыдущий год; после долгого простоя в январе
пропущенный август не догоняется. пропущенный август не догоняется.
@@ -126,9 +127,9 @@
## 5. LDD ## 5. LDD
- Реестр: `docs/evidence/claims.json`, **57 claims** (27 P0 / 30 P1). - Реестр: `docs/evidence/claims.json`, **58 claims** (27 P0 / 31 P1).
- `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0. - `./gradlew test ktlintCheck detekt evidenceCheck` — зелёный; P0 gaps = 0.
- `./scripts/ldd-release.sh` — PASS: **57/57 claims**, 366 тестов, mutation - `./scripts/ldd-release.sh` — PASS: **58/58 claims**, 368 тестов, mutation
`CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18, `CourseAccessService` 75%, `PromotionPlanner` 69%, browser E2E 18/18,
runtime/load p95 < 1 с. runtime/load p95 < 1 с.

View File

@@ -27,7 +27,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid).
| [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки |
| [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения | | [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения |
| [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки |
| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 57 claims, 366 тестов, upgrade, runtime/scale, mutation и browser E2E | | [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 58 claims, 368 тестов, upgrade, runtime/scale, mutation и browser E2E |
| [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение |
## Кратко о системе ## Кратко о системе

View File

@@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4.
LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых
рисков: рисков:
- **366 автоматических тестов**, 0 failures/errors; - **368 автоматических тестов**, 0 failures/errors;
- **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы, - **58 claims**: 27 P0 + 31 P1, **58/58 proven, P0 gaps = 0**. P0 — доступы,
повышение, жизненный цикл, профиль/скачивание, материалы/активы повышение, жизненный цикл, профиль/скачивание, материалы/активы
(включая secure-by-default legacy-файлы), миграции V11–V13, аудит, (включая secure-by-default legacy-файлы), миграции V11–V13, аудит,
refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт,

View File

@@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew test --rerun-tasks ./gradlew test --rerun-tasks
``` ```
Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**. Результат: **368 тестов, 0 failures, 0 errors, 0 skipped**.
`ktlintCheck` и `detekt` — зелёные. `ktlintCheck` и `detekt` — зелёные.
### 2.2. LDD gate ### 2.2. LDD gate
@@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре
./gradlew evidenceCheck ./gradlew evidenceCheck
``` ```
Результат: **57/57 claims proven, P0 gaps = 0**. Результат: **58/58 claims proven, P0 gaps = 0**.
Отчёт: `build/reports/evidence/index.md`. Отчёт: `build/reports/evidence/index.md`.
Gate устроен так: Gate устроен так:
@@ -306,8 +306,8 @@ optional json fields`.
## 5. Вывод ## 5. Вывод
Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**, Текущая функциональность прошла LDD-контур: **58/58 claims proven, P0 gaps = 0**,
**366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и **368 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и
масштабный load зелёные. Это достаточное основание продолжать доработки, но не масштабный load зелёные. Это достаточное основание продолжать доработки, но не
«формальное доказательство всей системы». «формальное доказательство всей системы».

View File

@@ -840,6 +840,20 @@
"ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()" "ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()"
], ],
"limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории." "limitations": "Админ видит все аудитории через админское дерево; публичное дерево с админским JWT также не скрывает аудитории."
},
{
"id": "LDD-IMPORT-003",
"title": "Ошибки импорта報告 по строкам до записи",
"criticality": "P1",
"status": "active",
"requirement": "docs/07-groups-courses.md, §9",
"claim": "Занятый логин и неактивная целевая группа возвращают applied=false с номером строки и не создают частичных студентов/зачислений.",
"oracle": "ImportResult.errors[].row и отсутствие записей после ошибки",
"evidence": [
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports occupied login with row and writes nothing()",
"ru.ysu.lms.identity.roster.GroupServiceIntegrationTest#import reports inactive target group and writes nothing()"
],
"limitations": "Неожиданные ошибки БД вне известных preflight-проверок всё ещё откатывают транзакцию без построчного отчёта."
} }
] ]
} }

View File

@@ -41,6 +41,23 @@ class GroupImportService(
restoreInactiveStatuses: Boolean = false, restoreInactiveStatuses: Boolean = false,
): ImportResult { ): ImportResult {
val parsed = parser.parse(filename, bytes) val parsed = parser.parse(filename, bytes)
validationFailure(parsed)?.let { return it }
val cohort = parsed.cohort ?: return ImportResult(applied = false)
val group =
try {
findOrCreateGroup(cohort)
} catch (ex: ConflictException) {
return ImportResult(
applied = false,
groupName = cohort.name,
errors = listOf(ImportError(parsed.students.firstOrNull()?.row ?: 0, ex.message.orEmpty())),
)
}
return applyStudents(parsed, cohort, group, actorAccountId, restoreInactiveStatuses)
}
/** Ошибки, которые должны быть показаны до любой записи в БД. */
private fun validationFailure(parsed: ParsedGroup): ImportResult? {
if (parsed.errors.isNotEmpty()) { if (parsed.errors.isNotEmpty()) {
return ImportResult( return ImportResult(
applied = false, applied = false,
@@ -55,11 +72,20 @@ class GroupImportService(
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")), errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
) )
} }
val group = findOrCreateGroup(cohort) val preflight = preflightErrors(parsed)
return if (preflight.isEmpty()) null else ImportResult(applied = false, groupName = cohort.name, errors = preflight)
}
private fun applyStudents(
parsed: ParsedGroup,
cohort: GroupCohort,
group: GroupEntity,
actorAccountId: UUID?,
restoreInactiveStatuses: Boolean,
): ImportResult {
val added = mutableListOf<Credential>() val added = mutableListOf<Credential>()
val updated = mutableListOf<String>() val updated = mutableListOf<String>()
val skipped = mutableListOf<ImportError>() val skipped = mutableListOf<ImportError>()
parsed.students.forEach { parsedStudent -> parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses) val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
if (student.status == StudentStatus.ACTIVE) { if (student.status == StudentStatus.ACTIVE) {
@@ -85,6 +111,26 @@ class GroupImportService(
) )
} }
/**
* Проверки, которые нельзя откладывать до записи: иначе конфликт уникальности
* откатит весь импорт без номера строки. Здесь же ловим дубли внутри файла.
*/
private fun preflightErrors(parsed: ParsedGroup): List<ImportError> {
val errors = mutableListOf<ImportError>()
val seen = HashSet<String>()
parsed.students.forEach { student ->
if (!seen.add(student.studentNo)) {
errors += ImportError(student.row, "Студент ${student.studentNo} повторяется в файле")
return@forEach
}
val exists = students.findByStudentNo(student.studentNo) != null
if (!exists && accounts.existsByLogin(student.studentNo)) {
errors += ImportError(student.row, "Логин ${student.studentNo} уже занят другим аккаунтом")
}
}
return errors
}
private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity { private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity {
val existing = val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear( groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(

View File

@@ -2,6 +2,7 @@ package ru.ysu.lms.identity.roster
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -16,12 +17,15 @@ import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginRequest import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.MembershipAction import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.StudentStatus import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.MembershipHistoryRepository import ru.ysu.lms.identity.repo.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID import java.util.UUID
@SpringBootTest @SpringBootTest
@@ -58,6 +62,8 @@ class GroupServiceIntegrationTest {
@Autowired lateinit var students: StudentRepository @Autowired lateinit var students: StudentRepository
@Autowired lateinit var accounts: AccountRepository
@Autowired lateinit var enrollments: EnrollmentRepository @Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var history: MembershipHistoryRepository @Autowired lateinit var history: MembershipHistoryRepository
@@ -241,6 +247,51 @@ class GroupServiceIntegrationTest {
assertTrue(skipped.message.contains("ACADEMIC_LEAVE")) assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
} }
@Test
fun `import reports occupied login with row and writes nothing`() {
accounts.saveAndFlush(
Account(
login = "3001",
passwordHash = "x",
role = LmsRole.STUDENT,
mustChangePassword = true,
),
)
val result =
importService.import(
"group.csv",
csv("ПИ;1;9;БО;2026;Иванов Иван;3001;бюджет"),
null,
)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
val error = result.errors.single()
assertEquals(2, error.row)
assertTrue(error.message.contains("Логин 3001"))
assertNull(students.findByStudentNo("3001"))
}
@Test
fun `import reports inactive target group and writes nothing`() {
val group = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 8, degree = "БО", enrollmentYear = 2026))
groupService.archiveGroup(UUID.fromString(group.id))
val result =
importService.import(
"group.csv",
csv("ПИ;1;8;БО;2026;Иванов Иван;3002;бюджет"),
null,
)
assertFalse(result.applied)
assertEquals(1, result.errors.size)
val error = result.errors.single()
assertEquals(2, error.row)
assertTrue(error.message.contains("не активна"))
assertNull(students.findByStudentNo("3002"))
}
@Test @Test
fun `import still updates an active student`() { fun `import still updates an active student`() {
importService.import("group.csv", csv(validRows()), null) importService.import("group.csv", csv(validRows()), null)