diff --git a/README.md b/README.md index 6a746c9..53beb08 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. отдельной CI VM: Woodpecker Docker backend, nested dind, Java 21, Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit. Установка — [`deploy/WOODPECKER.md`](deploy/WOODPECKER.md). - Baseline: **57 claims, 366 тестов, 18/18 browser E2E**. + Baseline: **58 claims, 368 тестов, 18/18 browser E2E**. ## Запуск в Docker (для сервера) diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index ace55e7..ad65a0f 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -336,10 +336,10 @@ identity V13, **academic V12**, progress V13. - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events` с `/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-матрица, - gateway, видимость активов и deploy-runbook. LDD baseline: **57 claims - (27 P0 / 30 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: + gateway, видимость активов и deploy-runbook. LDD baseline: **58 claims + (27 P0 / 31 P1), P0 gaps = 0**, browser E2E 18/18 (3 браузера). Отчёт: [16-stage6-report.md](16-stage6-report.md). ### 9.1. Известные пробелы после этапа 6 diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md index f3108d0..173dea3 100644 --- a/docs/16-stage6-report.md +++ b/docs/16-stage6-report.md @@ -65,15 +65,15 @@ | Модуль | Тестов | Failures | Errors | Skipped | |---|---:|---:|---:|---:| -| identity-service | 103 | 0 | 0 | 0 | +| identity-service | 105 | 0 | 0 | 0 | | academic-service | 106 | 0 | 0 | 0 | | progress-service | 38 | 0 | 0 | 0 | | web | 112 | 0 | 0 | 0 | | gateway | 2 | 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; маршрутизация gateway дополнительно проверена конфигурацией и security-матрицей ниже. Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на @@ -133,7 +133,7 @@ gateway дополнительно проверена конфигурацией ### 2.3. Security-проверки -Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures) +Проверено автоматическими тестами (актуальный прогон — 368 тестов, 0 failures) и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта negative-тестами «свой/чужой курс». @@ -347,10 +347,10 @@ web-сессия с отзывом и абсолютным потолком, key ## 7. LDD baseline После этапа 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, 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); конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. diff --git a/docs/17-handoff.md b/docs/17-handoff.md index 3367c67..16353f8 100644 --- a/docs/17-handoff.md +++ b/docs/17-handoff.md @@ -116,8 +116,8 @@ ### 1.8. LDD и проверки - Реестр: `docs/evidence/claims.json`. -- **57 claims**: 27 P0 + 30 P1; **57/57 proven, P0 gaps = 0**. -- **366 автотестов**, 0 failures/errors/skipped. +- **58 claims**: 27 P0 + 31 P1; **58/58 proven, P0 gaps = 0**. +- **368 автотестов**, 0 failures/errors/skipped. - Mutation testing: - `CourseAccessService` — 75% test strength; - `PromotionPlanner` — 69%. @@ -194,8 +194,8 @@ cat build/reports/evidence/index.md ``` Последний подтверждённый результат: -- `366` тестов, 0 failures; -- `57/57` claims, P0 gaps = 0; +- `368` тестов, 0 failures; +- `58/58` claims, P0 gaps = 0; - `ldd-release.sh` — PASS; - smoke — PASS; - browser E2E — 18/18 (Chromium/Firefox/WebKit); @@ -275,8 +275,8 @@ cat build/reports/evidence/index.md `feature/groups-courses`. 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и `docs/evidence/baseline-2026-10-27.md`. -3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 366 тестов - и 57 claims, 57/57 proven. +3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 368 тестов + и 58 claims, 58/58 proven. 4. Для release-проверки: `./scripts/ldd-release.sh`. 5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. 6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и diff --git a/docs/18-goal-audit.md b/docs/18-goal-audit.md index 365aa5c..8de67e2 100644 --- a/docs/18-goal-audit.md +++ b/docs/18-goal-audit.md @@ -26,7 +26,7 @@ | Backup/deploy | partial | `deploy.sh` теперь проверяет последний backup; restore на staging не подтверждён; первый деплой без backup — документированное исключение | | CI | partial | Конфигурация Woodpecker и локальные прогоны есть; первый реальный pipeline на VM не подтверждён | | Производительность | 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 не выполнена | | «Контингент» | out of scope | Исключён решением 2026-10-28 | @@ -83,8 +83,9 @@ ### P1 -- **Импорт: ошибки БД.** Ошибки lookup/конфликта всё ещё могут выйти одним - flash-сообщением без номера строки; нужен row-level отчёт и для них. +- **Импорт: ошибки БД.** Занятый логин и неактивная группа теперь дают + row-level `applied=false`; неожиданные DB-ошибки вне preflight всё ещё + откатывают транзакцию без построчного отчёта. - **Догон повышения.** `ensureScheduledDryRun` до 31 августа возвращает `null` и не создаёт план за предыдущий год; после долгого простоя в январе пропущенный август не догоняется. @@ -126,9 +127,9 @@ ## 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. -- `./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, runtime/load p95 < 1 с. diff --git a/docs/README.md b/docs/README.md index c8a0731..1de1da7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [18-goal-audit.md](18-goal-audit.md) | Сверка целевой модели из docs/07–09 с фактическим кодом: статусы, остаточные отличия, решения | | [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, миграции, доступ, статусы, повышение | ## Кратко о системе diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 32a970b..3fb5f8e 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -87,8 +87,8 @@ Platform 6 из Spring Boot 4. LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых рисков: -- **366 автоматических тестов**, 0 failures/errors; -- **57 claims**: 27 P0 + 30 P1, **57/57 proven, P0 gaps = 0**. P0 — доступы, +- **368 автоматических тестов**, 0 failures/errors; +- **58 claims**: 27 P0 + 31 P1, **58/58 proven, P0 gaps = 0**. P0 — доступы, повышение, жизненный цикл, профиль/скачивание, материалы/активы (включая secure-by-default legacy-файлы), миграции V11–V13, аудит, refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md index 583103a..bea95d6 100644 --- a/docs/evidence/baseline-2026-10-27.md +++ b/docs/evidence/baseline-2026-10-27.md @@ -40,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew test --rerun-tasks ``` -Результат: **366 тестов, 0 failures, 0 errors, 0 skipped**. +Результат: **368 тестов, 0 failures, 0 errors, 0 skipped**. `ktlintCheck` и `detekt` — зелёные. ### 2.2. LDD gate @@ -49,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew evidenceCheck ``` -Результат: **57/57 claims proven, P0 gaps = 0**. +Результат: **58/58 claims proven, P0 gaps = 0**. Отчёт: `build/reports/evidence/index.md`. Gate устроен так: @@ -306,8 +306,8 @@ optional json fields`. ## 5. Вывод -Текущая функциональность прошла LDD-контур: **57/57 claims proven, P0 gaps = 0**, -**366 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и +Текущая функциональность прошла LDD-контур: **58/58 claims proven, P0 gaps = 0**, +**368 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и масштабный load зелёные. Это достаточное основание продолжать доработки, но не «формальное доказательство всей системы». diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index d2300db..649b7e3 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -840,6 +840,20 @@ "ru.ysu.lms.academic.content.GlobalAudienceAccessIntegrationTest#course chapter never gets audience and cannot change it()" ], "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-проверок всё ещё откатывают транзакцию без построчного отчёта." } ] } \ No newline at end of file diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt index ac2402a..384891c 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupImportService.kt @@ -41,6 +41,23 @@ class GroupImportService( restoreInactiveStatuses: Boolean = false, ): ImportResult { 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()) { return ImportResult( applied = false, @@ -55,11 +72,20 @@ class GroupImportService( 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() val updated = mutableListOf() val skipped = mutableListOf() - parsed.students.forEach { parsedStudent -> val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses) if (student.status == StudentStatus.ACTIVE) { @@ -85,6 +111,26 @@ class GroupImportService( ) } + /** + * Проверки, которые нельзя откладывать до записи: иначе конфликт уникальности + * откатит весь импорт без номера строки. Здесь же ловим дубли внутри файла. + */ + private fun preflightErrors(parsed: ParsedGroup): List { + val errors = mutableListOf() + val seen = HashSet() + 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 { val existing = groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear( diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt index c22f46d..bb8f56f 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/roster/GroupServiceIntegrationTest.kt @@ -2,6 +2,7 @@ package ru.ysu.lms.identity.roster import org.junit.jupiter.api.Assertions.assertEquals 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.Test 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.identity.auth.AuthService 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.StudentStatus +import ru.ysu.lms.identity.repo.AccountRepository import ru.ysu.lms.identity.repo.EnrollmentRepository 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.util.UUID @SpringBootTest @@ -58,6 +62,8 @@ class GroupServiceIntegrationTest { @Autowired lateinit var students: StudentRepository + @Autowired lateinit var accounts: AccountRepository + @Autowired lateinit var enrollments: EnrollmentRepository @Autowired lateinit var history: MembershipHistoryRepository @@ -241,6 +247,51 @@ class GroupServiceIntegrationTest { 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 fun `import still updates an active student`() { importService.import("group.csv", csv(validRows()), null)