diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f523147..0c2413b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,7 @@ # должен разрешать privileged-сервисы (docker:dind) и сеть до docker:2375. stages: - check + - release-evidence variables: GRADLE_OPTS: "-Dorg.gradle.daemon=false" @@ -31,3 +32,31 @@ check: rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH' + +# Ручной release gate: runtime, mutation и browser evidence. +# Нужен privileged docker:dind; Chromium и Node ставятся в before_script. +release-evidence: + stage: release-evidence + image: eclipse-temurin:21-jdk + services: + - name: docker:27-dind + alias: docker + before_script: + - apt-get update + - apt-get install -y nodejs npm + - cd e2e && npm ci && npx playwright install --with-deps chromium && cd .. + cache: + key: "gradle-$CI_COMMIT_REF_SLUG" + paths: + - .gradle/caches + - .gradle/wrapper + script: + - ./scripts/ldd-release.sh + artifacts: + when: always + paths: + - build/evidence/ + - build/reports/evidence/ + rules: + - if: '$CI_COMMIT_BRANCH' + when: manual diff --git a/e2e/fixtures/main.c b/e2e/fixtures/main.c new file mode 100644 index 0000000..1a43931 --- /dev/null +++ b/e2e/fixtures/main.c @@ -0,0 +1,6 @@ +#include + +int main(void) { + puts("LDD e2e"); + return 0; +} diff --git a/e2e/lms.spec.ts b/e2e/lms.spec.ts index 95de14c..6d8fd8e 100644 --- a/e2e/lms.spec.ts +++ b/e2e/lms.spec.ts @@ -7,6 +7,7 @@ type Seeded = { courseNo: number; courseTitle: string; groupId: string; + labId: string; groupName: string; studentFullName: string; studentLogin: string; @@ -102,7 +103,7 @@ test.beforeAll(async ({ playwright }) => { await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/groups`, { groupId: group.id, }); - await createWithToken(api, adminToken, "post", "/api/v1/labs", { + const lab = await createWithToken(api, adminToken, "post", "/api/v1/labs", { number: 1, title: "E2E Lab", status: "published", @@ -126,6 +127,7 @@ test.beforeAll(async ({ playwright }) => { courseTitle: String(course.title), groupId: String(group.id), groupName: String(group.name), + labId: String(lab.id), studentFullName: String(student.fullName), studentLogin: studentNo, studentPassword: "e2e-student-456", @@ -186,3 +188,19 @@ test("anonymous and student cannot reach admin pages", async ({ page }) => { await page.goto(`/learn/course/${seeded.courseNo}`); await expect(page.locator("body")).toContainText(seeded.courseTitle); }); + +test("student submits a solution and mobile teacher page renders", async ({ page }) => { + await login(page, seeded.studentLogin, seeded.studentPassword, "**/student"); + await page.goto(`/student/labs/${seeded.labId}`); + await page.setInputFiles('input[name="files"]', "fixtures/main.c"); + await page.getByRole("button", { name: "Отправить решение" }).click(); + await expect(page.locator("body")).toContainText("Решение отправлено"); + await expect(page.locator("body")).toContainText("main.c"); + + await page.context().clearCookies(); + await page.setViewportSize({ width: 390, height: 844 }); + await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher"); + await page.goto(`/teacher/courses/${seeded.courseId}`); + await expect(page.locator("h1")).toContainText(seeded.courseTitle); + await expect(page.locator("body")).toContainText(seeded.groupName); +}); diff --git a/scripts/ldd-release.sh b/scripts/ldd-release.sh new file mode 100755 index 0000000..27e878b --- /dev/null +++ b/scripts/ldd-release.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E. +# Требует Docker, Java 21, Node.js/npm и установленный Chromium Playwright. +set -euo pipefail + +cd "$(dirname "$0")/.." + +log() { echo; echo ">> $*"; } + +log "1/6 Тесты, линтеры и evidence gate" +./gradlew test ktlintCheck detekt evidenceCheck --console=plain + +log "2/6 Upgrade с master-схемы" +./scripts/ldd-migration-upgrade.sh + +log "3/6 Mutation testing" +./scripts/ldd-mutation.sh + +log "4/6 Runtime smoke" +./scripts/ldd-runtime.sh + +log "5/6 Browser E2E" +./scripts/ldd-browser.sh + +log "6/6 Финальный evidence gate" +./gradlew evidenceCheck --console=plain + +echo +echo "LDD release evidence готов:" +echo " build/evidence/" +echo " build/reports/evidence/index.md" diff --git a/scripts/ldd-runtime.sh b/scripts/ldd-runtime.sh index 353cc97..924d416 100755 --- a/scripts/ldd-runtime.sh +++ b/scripts/ldd-runtime.sh @@ -78,8 +78,11 @@ mkdir -p "$(dirname "$EVIDENCE")" log "Проверяю HTTP-сценарии..." python3 - "$EVIDENCE" <<'PY' +import concurrent.futures import json +import statistics import sys +import time import urllib.error import urllib.request import uuid @@ -216,6 +219,22 @@ try: token=admin_token, detail="create lab", ) + student = expect( + "runtime.course_flow", + "POST", + gateway + "/api/v1/groups/" + group["id"] + "/students", + 200, + data={ + "studentNo": "ldd-student-" + suffix, + "lastName": "Тестов", + "firstName": "Студент", + "middleName": "", + "basis": "бюджет", + "note": "", + }, + token=admin_token, + detail="create student", + ) teacher_token = expect( "runtime.course_flow", "POST", @@ -283,6 +302,44 @@ try: raise AssertionError("audit actions") record("runtime.audit_visible", "passed", f"аудит содержит {sorted(required)}") + # 5b. Нагрузочный smoke чтения: курс, лабы, профиль и аудит. + load_paths = [ + (gateway + "/api/v1/courses/" + course["id"], teacher_token), + (gateway + "/api/v1/labs?courseId=" + course["id"], teacher_token), + (gateway + "/api/v1/courses/" + course["id"] + "/students/" + student["id"] + "/profile", teacher_token), + (gateway + "/api/v1/audit?limit=100", admin_token), + ] + + def timed_get(path, token): + started = time.perf_counter() + status, _ = req("GET", path, token=token) + return status, time.perf_counter() - started + + latencies = [] + errors = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool: + futures = [ + pool.submit(timed_get, path, token) + for _ in range(25) + for path, token in load_paths + ] + for future in concurrent.futures.as_completed(futures): + status, latency = future.result() + latencies.append(latency) + if status != 200: + errors += 1 + latencies.sort() + p95 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.95))] + average = statistics.mean(latencies) + load_ok = errors == 0 and p95 < 2.0 + record( + "runtime.load_gradebook_profile", + "passed" if load_ok else "failed", + f"requests={len(latencies)}, errors={errors}, avg={average:.3f}s, p95={p95:.3f}s", + ) + if not load_ok: + raise AssertionError("load smoke") + # 6. Web-страница входа и публичное дерево. status, _ = req("GET", web + "/login") if status != 200: diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionApplyRollbackPropertyTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionApplyRollbackPropertyTest.kt new file mode 100644 index 0000000..75039d5 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionApplyRollbackPropertyTest.kt @@ -0,0 +1,113 @@ +package ru.ysu.lms.identity.promotion + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +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.identity.domain.GroupStatus +import ru.ysu.lms.identity.domain.PromotionAction +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.StudentRepository +import ru.ysu.lms.identity.roster.CreateGroupRequest +import ru.ysu.lms.identity.roster.CreateStudentRequest +import ru.ysu.lms.identity.roster.GroupService +import java.util.UUID +import kotlin.random.Random + +/** + * Model-based проверка транзакционного apply/rollback на сгенерированных + * когортах: после apply состояние соответствует плану, после rollback — + * ровно before-state. + */ +@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"]) +@Testcontainers +@Transactional +class PromotionApplyRollbackPropertyTest { + companion object { + @Container + val pg = + PostgreSQLContainer("postgres:18").apply { + withDatabaseName("lms") + withUsername("lms") + withPassword("lms") + } + + @JvmStatic + @DynamicPropertySource + fun datasource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { pg.jdbcUrl } + registry.add("spring.datasource.username") { "lms" } + registry.add("spring.datasource.password") { "lms" } + } + } + + @Autowired lateinit var promotionService: PromotionService + + @Autowired lateinit var groupService: GroupService + + @Autowired lateinit var groups: GroupRepository + + @Autowired lateinit var students: StudentRepository + + @Test + fun `generated cohorts apply to plan and rollback to before-state`() { + val random = Random(20261029) + repeat(10) { index -> + val degree = if (random.nextBoolean()) "БО" else "МО" + val limit = if (degree == "БО") 4 else 2 + val course = random.nextInt(1, limit + 1) + val year = 2040 + index + val group = + groupService.create( + CreateGroupRequest( + stream = "PR", + course = course.toShort(), + number = (index + 1).toShort(), + degree = degree, + enrollmentYear = (year - 1).toShort(), + ), + ) + val beforeName = group.name + val student = + groupService.createStudent( + UUID.fromString(group.id), + CreateStudentRequest( + studentNo = "PR-$index-${UUID.randomUUID().toString().take(8)}", + lastName = "Тестов", + ), + null, + ) + val studentId = UUID.fromString(student.id) + val plan = promotionService.dryRun(year, null) + val planItem = plan.items.first { it.entityId == group.id } + val expectedAction = if (course < limit) PromotionAction.PROMOTE else PromotionAction.GRADUATE + + assertEquals(expectedAction.name, planItem.action) + promotionService.apply(UUID.fromString(plan.id), null) + val afterApply = groups.findById(UUID.fromString(group.id)).orElseThrow() + if (course < limit) { + assertEquals((course + 1).toShort(), afterApply.course) + assertTrue(afterApply.name != beforeName) + } else { + assertEquals(GroupStatus.GRADUATED, afterApply.status) + } + + promotionService.rollback(UUID.fromString(plan.id), null) + val afterRollback = groups.findById(UUID.fromString(group.id)).orElseThrow() + assertEquals(beforeName, afterRollback.name) + assertEquals(course.toShort(), afterRollback.course) + assertEquals(GroupStatus.ACTIVE, afterRollback.status) + assertEquals( + students.findById(studentId).orElseThrow().status, + ru.ysu.lms.identity.domain.StudentStatus.ACTIVE, + ) + } + } +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionConcurrencyIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionConcurrencyIntegrationTest.kt new file mode 100644 index 0000000..f7fd7ac --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionConcurrencyIntegrationTest.kt @@ -0,0 +1,140 @@ +package ru.ysu.lms.identity.promotion + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import ru.ysu.lms.common.ConflictException +import ru.ysu.lms.identity.domain.GroupStatus +import ru.ysu.lms.identity.domain.MembershipAction +import ru.ysu.lms.identity.domain.PromotionMode +import ru.ysu.lms.identity.domain.PromotionRunStatus +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.MembershipHistoryRepository +import ru.ysu.lms.identity.repo.PromotionRunRepository +import ru.ysu.lms.identity.roster.CreateGroupRequest +import ru.ysu.lms.identity.roster.CreateStudentRequest +import ru.ysu.lms.identity.roster.GroupService +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Конкурентный apply/rollback одного запуска. Класс не @Transactional: + * каждый вызов сервиса должен получить собственную транзакцию и advisory lock. + */ +@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"]) +@Testcontainers +class PromotionConcurrencyIntegrationTest { + companion object { + @Container + val pg = + PostgreSQLContainer("postgres:18").apply { + withDatabaseName("lms") + withUsername("lms") + withPassword("lms") + } + + @JvmStatic + @DynamicPropertySource + fun datasource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { pg.jdbcUrl } + registry.add("spring.datasource.username") { "lms" } + registry.add("spring.datasource.password") { "lms" } + } + } + + @Autowired lateinit var promotionService: PromotionService + + @Autowired lateinit var groupService: GroupService + + @Autowired lateinit var groups: GroupRepository + + @Autowired lateinit var history: MembershipHistoryRepository + + @Autowired lateinit var runs: PromotionRunRepository + + @Test + fun `concurrent apply of one run is idempotent and writes history once`() { + val fixture = fixture(number = 1, studentNo = "CONC-APPLY", year = 2030) + val plan = promotionService.dryRun(2030, null) + val runId = UUID.fromString(plan.id) + + val results = runConcurrently { promotionService.apply(runId, null) } + + assertTrue(results.all { it.isSuccess }, "оба apply должны завершиться идемпотентно") + assertEquals(1, runs.findAll().count { it.year == 2030 && it.mode == PromotionMode.APPLY }) + assertEquals(1, history.findByStudentIdOrderByAtDesc(fixture.studentId).count { it.action == MembershipAction.PROMOTE }) + assertEquals(2.toShort(), groups.findById(fixture.groupId).orElseThrow().course) + } + + @Test + fun `concurrent rollback allows exactly one rollback and restores once`() { + val fixture = fixture(number = 2, studentNo = "CONC-ROLLBACK", year = 2031) + val plan = promotionService.dryRun(2031, null) + val runId = UUID.fromString(plan.id) + promotionService.apply(runId, null) + + val results = runConcurrently { promotionService.rollback(runId, null) } + + assertEquals(1, results.count { it.isSuccess }) + assertEquals(1, results.count { it.exceptionOrNull() is ConflictException }) + assertEquals(PromotionRunStatus.ROLLED_BACK, runs.findById(runId).orElseThrow().status) + assertEquals(1.toShort(), groups.findById(fixture.groupId).orElseThrow().course) + assertEquals(GroupStatus.ACTIVE, groups.findById(fixture.groupId).orElseThrow().status) + } + + private fun runConcurrently(block: () -> Unit): List> { + val start = CountDownLatch(1) + val pool = Executors.newFixedThreadPool(2) + try { + val futures = + (1..2).map { + pool.submit> { + start.await() + runCatching { block() } + } + } + start.countDown() + return futures.map { it.get(30, TimeUnit.SECONDS) } + } finally { + pool.shutdownNow() + } + } + + private fun fixture( + number: Short, + studentNo: String, + year: Int, + ): Fixture { + val group = + groupService.create( + CreateGroupRequest( + stream = "ПИ", + course = 1, + number = number, + degree = "БО", + enrollmentYear = (year - 1).toShort(), + ), + ) + val student = + groupService.createStudent( + UUID.fromString(group.id), + CreateStudentRequest(studentNo = studentNo, lastName = "Тестов"), + null, + ) + return Fixture(UUID.fromString(group.id), UUID.fromString(student.id)) + } + + private data class Fixture( + val groupId: UUID, + val studentId: UUID, + ) +}