From 905feb440b8831dd9fd33d2b4270443eba66fd07 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 28 Oct 2026 00:01:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(ldd):=20property-based,=20mutation=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + docs/evidence/claims.json | 44 ++- e2e/lms.spec.ts | 188 +++++++++++++ e2e/package-lock.json | 58 ++++ e2e/package.json | 7 + e2e/playwright.config.ts | 27 ++ gradle/libs.versions.toml | 7 + libs/test-support/build.gradle.kts | 1 + scripts/ldd-browser.sh | 113 ++++++++ scripts/ldd-mutation.sh | 68 +++++ services/academic-service/build.gradle.kts | 56 ++++ .../course/CourseAccessServiceMutationTest.kt | 257 ++++++++++++++++++ .../course/CourseAccessServicePropertyTest.kt | 153 +++++++++++ services/identity-service/build.gradle.kts | 55 ++++ .../promotion/PromotionPlannerMutationTest.kt | 248 +++++++++++++++++ .../promotion/PromotionPlannerPropertyTest.kt | 145 ++++++++++ services/progress-service/build.gradle.kts | 2 + web/build.gradle.kts | 2 + 18 files changed, 1432 insertions(+), 2 deletions(-) create mode 100644 e2e/lms.spec.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100755 scripts/ldd-browser.sh create mode 100755 scripts/ldd-mutation.sh create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServicePropertyTest.kt create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerMutationTest.kt create mode 100644 services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerPropertyTest.kt diff --git a/.gitignore b/.gitignore index fd2ce03..d258890 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ build/ /exports/ /texput.log node_modules/ +.jqwik-database +e2e/test-results/ +e2e/playwright-report/ # Локальные сессии Kilo Code — не часть репозитория. .kilo/ # Каталог с боевыми данными и паролями (не коммитить!) diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index 667a222..c141ada 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -394,7 +394,8 @@ "ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()", "ru.ysu.lms.web.api.CourseGroupRosterDtoTest#deserializes course group roster contract()", "ru.ysu.lms.web.api.AuditDtoTest#deserializes audit event list with nulls()", - "ru.ysu.lms.web.api.PromotionDtoTest#deserializes promotion detail with item states and result()" + "ru.ysu.lms.web.api.PromotionDtoTest#deserializes promotion detail with item states and result()", + "ru.ysu.lms.identity.roster.TeacherIntegrationTest#student creation accepts omitted optional json fields()" ], "limitations": "Это smoke-контракты, не consumer-driven contracts." }, @@ -458,6 +459,45 @@ "runtime.web_login_page" ], "limitations": "Smoke не заменяет полный UI E2E и нагрузочные тесты." + }, + { + "id": "LDD-MUTATION-001", + "title": "Мутационная прочность матрицы доступа", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "PIT по CourseAccessService убивает не менее 70% покрытых мутаций.", + "oracle": "PIT mutations.xml: killed / covered", + "runtime": [ + "mutation.course_access" + ], + "limitations": "PIT 1.20 + JUnit4 harness; Kotlin/Spring-специфичные мутации и эквивалентные мутанты не входят в цель." + }, + { + "id": "LDD-MUTATION-002", + "title": "Мутационная прочность планировщика повышения", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "PIT по PromotionPlanner убивает не менее 60% покрытых мутаций.", + "oracle": "PIT mutations.xml: killed / covered", + "runtime": [ + "mutation.promotion_planner" + ], + "limitations": "Проверяется планировщик, не транзакционный apply/rollback (они покрыты интеграционными тестами)." + }, + { + "id": "LDD-BROWSER-001", + "title": "Браузерный E2E админа, преподавателя и студента", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "Playwright на реальном стеке: admin видит курс и аудит, teacher открывает курс/ведомость/профиль и создаёт главу, аноним и студент не попадают в админку.", + "oracle": "Playwright assertions на DOM/URL", + "runtime": [ + "browser.e2e" + ], + "limitations": "Один Chromium, один сценарий на роль; визуальная регрессия и мобильные размеры не проверяются." } ] -} +} \ No newline at end of file diff --git a/e2e/lms.spec.ts b/e2e/lms.spec.ts new file mode 100644 index 0000000..95de14c --- /dev/null +++ b/e2e/lms.spec.ts @@ -0,0 +1,188 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const apiBase = process.env.LDD_API_URL ?? "http://localhost:8080"; + +type Seeded = { + courseId: string; + courseNo: number; + courseTitle: string; + groupId: string; + groupName: string; + studentFullName: string; + studentLogin: string; + studentPassword: string; + teacherLogin: string; + teacherPassword: string; + adminLogin: string; + adminPassword: string; +}; + +let seeded: Seeded; +let api: APIRequestContext; + +async function expectOk(response: Awaited>): Promise { + if (!response.ok()) { + throw new Error(`${response.url()} -> ${response.status()}: ${await response.text()}`); + } + return response.json(); +} + +async function setupAdmin(context: APIRequestContext): Promise { + const health = await context.get("/api/v1/auth/health"); + const healthBody = (await health.json()) as { adminExists: boolean }; + if (healthBody.adminExists) { + const login = await context.post("/api/v1/auth/login", { + data: { login: "e2e-admin", password: "e2e-admin-123" }, + }); + return ((await expectOk(login)) as { token: string }).token; + } + const setup = await context.post("/api/v1/auth/setup", { + data: { login: "e2e-admin", password: "e2e-admin-123", password2: "e2e-admin-123" }, + }); + return ((await expectOk(setup)) as { token: string }).token; +} + +async function createWithToken( + context: APIRequestContext, + token: string, + method: "post", + path: string, + data: unknown, +): Promise> { + const response = await context[method](path, { + data, + headers: { Authorization: `Bearer ${token}` }, + }); + return (await expectOk(response)) as Record; +} + +async function changePassword(context: APIRequestContext, login: string, oldPassword: string, newPassword: string) { + const loginResponse = await context.post("/api/v1/auth/login", { + data: { login, password: oldPassword }, + }); + const token = ((await expectOk(loginResponse)) as { token: string }).token; + await expectOk( + await context.post("/api/v1/auth/change-password", { + headers: { Authorization: `Bearer ${token}` }, + data: { old: oldPassword, new: newPassword, new2: newPassword }, + }), + ); +} + +async function login(page: Page, login: string, password: string, expectedPath: RegExp | string) { + await page.goto("/login"); + await page.fill("#login", login); + await page.fill("#password", password); + await Promise.all([page.waitForURL(expectedPath), page.click('button[type="submit"]')]); +} + +test.beforeAll(async ({ playwright }) => { + api = await playwright.request.newContext({ baseURL: apiBase }); + const suffix = Math.random().toString(36).slice(2, 8); + const adminToken = await setupAdmin(api); + + const group = await createWithToken(api, adminToken, "post", "/api/v1/groups", { + stream: "E2E" + suffix, + course: 1, + number: 1, + degree: "БО", + enrollmentYear: 2026, + }); + const course = await createWithToken(api, adminToken, "post", "/api/v1/courses", { + title: "E2E Course " + suffix, + }); + const teacher = await createWithToken(api, adminToken, "post", "/api/v1/teachers", { + login: "e2e-teacher-" + suffix, + password: "e2e-teacher-123", + }); + const teacherLogin = String(teacher.login); + await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/teachers`, { + accountId: teacher.id, + }); + await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/groups`, { + groupId: group.id, + }); + await createWithToken(api, adminToken, "post", "/api/v1/labs", { + number: 1, + title: "E2E Lab", + status: "published", + courseId: course.id, + levels: [{ code: "basic", title: "Базовый", position: 1 }], + }); + const studentNo = "E2E-S-" + suffix; + const student = await createWithToken(api, adminToken, "post", `/api/v1/groups/${group.id}/students`, { + studentNo, + lastName: "Тестов", + firstName: "Студент", + basis: "бюджет", + }); + const studentPassword = String(student.password); + await changePassword(api, teacherLogin, "e2e-teacher-123", "e2e-teacher-456"); + await changePassword(api, studentNo, studentPassword, "e2e-student-456"); + + seeded = { + courseId: String(course.id), + courseNo: Number(course.courseNo), + courseTitle: String(course.title), + groupId: String(group.id), + groupName: String(group.name), + studentFullName: String(student.fullName), + studentLogin: studentNo, + studentPassword: "e2e-student-456", + teacherLogin, + teacherPassword: "e2e-teacher-456", + adminLogin: "e2e-admin", + adminPassword: "e2e-admin-123", + }; +}); + +test.afterAll(async () => { + await api.dispose(); +}); + +test("admin opens courses and audit", async ({ page }) => { + await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin"); + await page.goto("/admin/courses"); + await expect(page.locator("body")).toContainText(seeded.courseTitle); + + await page.goto("/admin/audit"); + await expect(page.locator("body")).toContainText("Создан курс"); +}); + +test("teacher opens course, gradebook, student profile and authors a chapter", async ({ page }) => { + await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher"); + await expect(page.locator("body")).toContainText(seeded.courseTitle); + + await page.goto(`/teacher/courses/${seeded.courseId}`); + await expect(page.locator("h1")).toContainText(seeded.courseTitle); + await expect(page.locator("body")).toContainText(seeded.groupName); + await expect(page.locator("body")).toContainText("E2E Lab"); + + await page.getByRole("link", { name: "Ведомость" }).click(); + await expect(page).toHaveURL(new RegExp(`/admin/groups/${seeded.groupId}\\?courseId=`)); + await expect(page.locator("body")).toContainText(seeded.groupName); + await expect(page.locator("body")).toContainText(seeded.studentFullName); + await expect(page.getByRole("button", { name: "Отчислить" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Сбросить" })).toHaveCount(0); + + await page.getByRole("link", { name: seeded.studentFullName }).first().click(); + await expect(page).toHaveURL(new RegExp(`/teacher/courses/${seeded.courseId}/students/`)); + await expect(page.locator("body")).toContainText(seeded.studentFullName); + + await page.goto(`/teacher/courses/${seeded.courseId}/content`); + await page.fill("#chapterTitle", "E2E Chapter"); + await page.getByRole("button", { name: "Создать главу" }).click(); + await expect(page.locator("body")).toContainText("E2E Chapter"); +}); + +test("anonymous and student cannot reach admin pages", async ({ page }) => { + await page.goto("/admin/audit"); + await expect(page).toHaveURL(/\/login/); + + await login(page, seeded.studentLogin, seeded.studentPassword, "**/student"); + await page.goto("/admin/audit"); + await expect(page).toHaveURL(/\/student/); + + await page.goto(`/learn/course/${seeded.courseNo}`); + await expect(page.locator("body")).toContainText(seeded.courseTitle); +}); diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..ea6d254 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "lms-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lms-e2e", + "devDependencies": { + "@playwright/test": "1.64.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..5aa9d24 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,7 @@ +{ + "name": "lms-e2e", + "private": true, + "devDependencies": { + "@playwright/test": "1.64.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..486d8bb --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + timeout: 45_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + forbidOnly: true, + reporter: [ + ["list"], + ["json", { outputFile: "../build/evidence/playwright.json" }], + ], + use: { + baseURL: process.env.LDD_WEB_URL ?? "http://localhost:8090", + headless: true, + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0e454a..a3ee421 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,9 @@ apachePoi = "5.5.1" ktlint = "14.2.0" detekt = "1.23.8" mockk = "1.14.11" +jqwik = "1.9.3" +pitest = "1.15.0" +pitestJunit5 = "1.2.1" [libraries] # --- Gradle plugins used from buildSrc --- @@ -58,6 +61,7 @@ flywayPostgresql = { module = "org.flywaydb:flyway-database-postgresql" } postgresql = { module = "org.postgresql:postgresql" } jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" } jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" } +jackson3ModuleKotlin = { module = "tools.jackson.module:jackson-module-kotlin", version = "3.1.5" } # --- API docs --- springdocStarterWebmvcUi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } @@ -71,6 +75,9 @@ apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi # --- Tests / Testcontainers (versions managed by the Boot BOM) --- mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +jqwik = { module = "net.jqwik:jqwik", version.ref = "jqwik" } +pitestCommandLine = { module = "org.pitest:pitest-command-line", version.ref = "pitest" } +pitestJunit5Plugin = { module = "org.pitest:pitest-junit5-plugin", version.ref = "pitestJunit5" } junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" } springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" } diff --git a/libs/test-support/build.gradle.kts b/libs/test-support/build.gradle.kts index 488808f..b7a7185 100644 --- a/libs/test-support/build.gradle.kts +++ b/libs/test-support/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { api(libs.testcontainersJunitJupiter) api(libs.springBootStarterTest) api(libs.mockk) + api(libs.jqwik) // Gradle 9 требует launcher JUnit Platform на тестовом classpath явно. api(libs.junitPlatformLauncher) } diff --git a/scripts/ldd-browser.sh b/scripts/ldd-browser.sh new file mode 100755 index 0000000..4287832 --- /dev/null +++ b/scripts/ldd-browser.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# LDD browser E2E: полный стек + Playwright (Chromium) на одноразовой БД. +set -euo pipefail + +cd "$(dirname "$0")/.." + +DB="lms_e2e_$(date -u +%H%M%S)_$$" +POSTGRES_WAS_RUNNING=0 +STACK_STARTED=0 +E2E_STATUS=1 + +log() { echo ">> $*"; } + +cleanup() { + local code=$? + if ((STACK_STARTED)); then + log "Останавливаю тестовый стек..." + ./run.sh stop >/dev/null 2>&1 || true + fi + docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS $DB;" >/dev/null 2>&1 || true + if ((POSTGRES_WAS_RUNNING == 0)); then + docker compose stop postgres >/dev/null 2>&1 || true + fi + exit $code +} +trap cleanup EXIT INT TERM + +for port in 8080 8081 8082 8083 8090; do + if ss -ltn 2>/dev/null | grep -q ":$port "; then + echo "Порт $port занят. Остановите текущий стек (./run.sh stop) и повторите." >&2 + exit 1 + fi +done + +command -v docker >/dev/null 2>&1 || { echo "Нужен docker" >&2; exit 1; } +command -v npx >/dev/null 2>&1 || { echo "Нужен Node.js/npx" >&2; exit 1; } +JAVA_BIN="${JAVA_BIN:-/usr/lib/jvm/java-21-openjdk/bin/java}" +[[ -x "$JAVA_BIN" ]] || { echo "Не найден Java 21: $JAVA_BIN" >&2; exit 1; } +[[ -d e2e/node_modules ]] || { echo "Выполните: cd e2e && npm install" >&2; exit 1; } + +if docker compose ps --status running --services 2>/dev/null | grep -qx postgres; then + POSTGRES_WAS_RUNNING=1 +fi + +log "Запускаю PostgreSQL..." +docker compose up -d postgres >/dev/null +for _ in $(seq 1 40); do + docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null 2>&1 && break + sleep 1 +done +docker exec lms-postgres pg_isready -U lms -d postgres >/dev/null +docker exec -i lms-postgres psql -U lms -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE $DB;" >/dev/null + +log "Собираю bootJar..." +./gradlew bootJar --console=plain >/tmp/lms-ldd-e2e-bootjar.log + +log "Запускаю стек..." +export LMS_DB_URL="jdbc:postgresql://localhost:5432/$DB" +export LMS_DB_NAME="$DB" +export LMS_DB_USER="lms" +export LMS_DB_PASSWORD="lms" +export LMS_INTERNAL_TOKEN="ldd-internal-token" +export LMS_SESSION_COOKIE_SECURE="false" +export JAVA_BIN +STACK_STARTED=1 +./run.sh start + +mkdir -p build/evidence +log "Запускаю Playwright..." +set +e +( + cd e2e + LDD_WEB_URL="http://localhost:8090" \ + LDD_API_URL="http://localhost:8080" \ + npx playwright test --config=playwright.config.ts +) +E2E_STATUS=$? +set -e + +python3 - "$E2E_STATUS" <<'PY' +import json +import os +import sys + +status_code = int(sys.argv[1]) +playwright_path = "build/evidence/playwright.json" +detail = "Playwright exit=" + str(status_code) +if os.path.isfile(playwright_path): + try: + root = json.load(open(playwright_path, encoding="utf-8")) + stats = root.get("stats", {}) + detail += ( + f"; expected={stats.get('expected', 0)}" + f", unexpected={stats.get('unexpected', 0)}" + f", flaky={stats.get('flaky', 0)}" + f", skipped={stats.get('skipped', 0)}" + ) + except Exception as exc: + detail += "; не удалось разобрать playwright.json: " + str(exc) +status = "passed" if status_code == 0 else "failed" +with open("build/evidence/browser.json", "w", encoding="utf-8") as fh: + now = __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat() + json.dump( + {"generatedAt": now, "checks": [{"id": "browser.e2e", "status": status, "detail": detail}]}, + fh, + ensure_ascii=False, + indent=2, + ) +print(("PASS " if status == "passed" else "FAIL ") + "browser.e2e: " + detail) +sys.exit(0 if status == "passed" else 1) +PY diff --git a/scripts/ldd-mutation.sh b/scripts/ldd-mutation.sh new file mode 100755 index 0000000..3e2cfac --- /dev/null +++ b/scripts/ldd-mutation.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# LDD mutation evidence: PIT для критичных P0-компонентов. +# JUnit 4 harness используется потому, что JUnit5-плагин PIT несовместим с JUnit Platform 6. +set -euo pipefail + +cd "$(dirname "$0")/.." + +./gradlew \ + :services:academic-service:pitestCourseAccess \ + :services:identity-service:pitestPromotionPlanner \ + --console=plain + +mkdir -p build/evidence + +python3 - <<'PY' +import json +import os +import xml.etree.ElementTree as ET + +checks = [] + + +def parse(path): + root = ET.parse(path).getroot() + total = killed = no_coverage = 0 + for mutation in root.findall("mutation"): + total += 1 + status = mutation.attrib.get("status") + if status == "KILLED": + killed += 1 + elif status == "NO_COVERAGE": + no_coverage += 1 + covered = total - no_coverage + strength = (killed / covered * 100) if covered else 0.0 + return total, killed, no_coverage, strength + + +def check(check_id, path, threshold): + if not os.path.isfile(path): + checks.append({"id": check_id, "status": "failed", "detail": "нет отчёта " + path}) + return + total, killed, no_coverage, strength = parse(path) + status = "passed" if strength >= threshold else "failed" + detail = ( + f"mutations={total}, killed={killed}, no_coverage={no_coverage}, " + f"test_strength={strength:.1f}% (threshold {threshold:.0f}%)" + ) + checks.append({"id": check_id, "status": status, "detail": detail}) + print(("PASS " if status == "passed" else "FAIL ") + check_id + ": " + detail) + + +check( + "mutation.course_access", + "services/academic-service/build/reports/pitest-course-access/mutations.xml", + 70.0, +) +check( + "mutation.promotion_planner", + "services/identity-service/build/reports/pitest-promotion-planner/mutations.xml", + 60.0, +) + +with open("build/evidence/mutation.json", "w", encoding="utf-8") as fh: + json.dump({"generatedAt": __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat(), "checks": checks}, fh, ensure_ascii=False, indent=2) + +if any(c["status"] != "passed" for c in checks): + raise SystemExit(1) +PY diff --git a/services/academic-service/build.gradle.kts b/services/academic-service/build.gradle.kts index e8d91ee..316c4ae 100644 --- a/services/academic-service/build.gradle.kts +++ b/services/academic-service/build.gradle.kts @@ -2,6 +2,58 @@ plugins { id("lms.spring-boot") } +/** PIT запускается отдельной задачей: Gradle-плагин 1.15 несовместим с Gradle 9.6. */ +val pitest by configurations.creating + +dependencies { + add("pitest", libs.pitestCommandLine) +} + +tasks.register("pitestCourseAccess") { + group = "verification" + description = "Mutation testing CourseAccessService (P0 access matrix)" + dependsOn(tasks.testClasses) + classpath = pitest + sourceSets.getByName("test").runtimeClasspath + mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport") + val reportDir = + layout.buildDirectory + .dir("reports/pitest-course-access") + .get() + .asFile + .absolutePath + val sourceDirs = + sourceSets.main + .get() + .kotlin.srcDirs + .joinToString(",") { it.absolutePath } + val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath + args( + "--reportDir", + reportDir, + "--targetClasses", + "ru.ysu.lms.academic.course.CourseAccessService", + "--targetTests", + "ru.ysu.lms.academic.course.CourseAccessServiceMutationTest", + "--sourceDirs", + sourceDirs, + "--classPath", + testClasspath, + "--mutators", + "STRONGER", + "--threads", + "1", + "--verbose", + "--outputFormats", + "XML,HTML", + "--timestampedReports", + "false", + "--timeoutConst", + "15000", + "--failWhenNoMutations", + "true", + ) +} + dependencies { implementation(platform(libs.springBootBom)) @@ -16,6 +68,7 @@ dependencies { runtimeOnly(libs.postgresql) implementation(libs.jacksonYaml) implementation(libs.jacksonModuleKotlin) + implementation(libs.jackson3ModuleKotlin) implementation(libs.springdocStarterWebmvcUi) implementation(project(":libs:common-http")) @@ -24,6 +77,9 @@ dependencies { testImplementation(project(":libs:test-support")) testImplementation(libs.springSecurityTest) + // PIT 1.15/1.20 JUnit5-плагин несовместим с JUnit Platform 6 (Spring Boot 4): + // mutation harness запускается через встроенный JUnit 4 plugin. + testImplementation("junit:junit:4.13.2") } springBoot { diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt new file mode 100644 index 0000000..8890c48 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceMutationTest.kt @@ -0,0 +1,257 @@ +package ru.ysu.lms.academic.course + +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test +import org.springframework.security.oauth2.jwt.Jwt +import ru.ysu.lms.academic.domain.Course +import ru.ysu.lms.academic.domain.CourseGroup +import ru.ysu.lms.academic.domain.CourseTeacher +import ru.ysu.lms.academic.domain.Lab +import ru.ysu.lms.academic.identity.IdentityClient +import ru.ysu.lms.academic.repo.CourseGroupRepository +import ru.ysu.lms.academic.repo.CourseRepository +import ru.ysu.lms.academic.repo.CourseTeacherRepository +import ru.ysu.lms.academic.repo.LabRepository +import ru.ysu.lms.common.ForbiddenException +import java.util.Optional +import java.util.UUID + +/** + * JUnit 4 harness для PIT: JUnit5-плагин PIT несовместим с JUnit Platform 6, + * а встроенный JUnit 4 runner — совместим. Обычный `./gradlew test` этот класс + * не запускает; он нужен только задаче `pitestCourseAccess`. + */ +class CourseAccessServiceMutationTest { + private val courses = mockk() + private val teachers = mockk() + private val groups = mockk() + private val labs = mockk() + private val identity = mockk() + private val courseService = mockk() + private val service = + CourseAccessService( + courses, + teachers, + groups, + labs, + identity, + courseService, + ) + + @Test + fun `admin bypasses archived course`() { + val archived = course(Course.STATUS_ARCHIVED) + assertThatCode { + service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_ADMIN)) + }.doesNotThrowAnyException() + } + + @Test + fun `teacher assigned active course allowed`() { + val active = course(Course.STATUS_ACTIVE) + val teacherId = UUID.randomUUID() + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf(CourseTeacher(course = active, accountId = teacherId)) + every { courses.findAllById(any()) } returns listOf(active) + + assertThatCode { + service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) + }.doesNotThrowAnyException() + } + + @Test + fun `teacher foreign and archived courses denied`() { + val assigned = course(Course.STATUS_ACTIVE) + val foreign = course(Course.STATUS_ACTIVE) + val archived = course(Course.STATUS_ARCHIVED) + val teacherId = UUID.randomUUID() + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf(CourseTeacher(course = assigned, accountId = teacherId)) + every { courses.findAllById(any()) } returns listOf(assigned, archived) + + assertThatThrownBy { + service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) + }.isInstanceOf(ForbiddenException::class.java) + assertThatThrownBy { + service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) + }.isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `teacher without active assignment denied`() { + val teacherId = UUID.randomUUID() + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList() + + assertThatThrownBy { + service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) + }.isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `student current active group allowed and foreign denied`() { + val active = course(Course.STATUS_ACTIVE) + val foreign = course(Course.STATUS_ACTIVE) + val groupId = UUID.randomUUID() + every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) + every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns + listOf(CourseGroup(course = active, groupId = groupId)) + every { courses.findAllById(any()) } returns listOf(active) + + assertThatCode { + service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_STUDENT)) + }.doesNotThrowAnyException() + assertThatThrownBy { + service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_STUDENT)) + }.isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `student without group and archived course denied`() { + every { identity.currentGroupIds(TOKEN) } returns emptyList() + assertThatThrownBy { + service.requireCourseAccess(course(Course.STATUS_ACTIVE).id, jwt(CourseAccessService.ROLE_STUDENT)) + }.isInstanceOf(ForbiddenException::class.java) + + val archived = course(Course.STATUS_ARCHIVED) + val groupId = UUID.randomUUID() + every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) + every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns + listOf(CourseGroup(course = archived, groupId = groupId)) + every { courses.findAllById(any()) } returns listOf(archived) + assertThatThrownBy { + service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_STUDENT)) + }.isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `lab without course is admin only`() { + val lab = Lab(number = 1, title = "Без курса") + every { labs.findById(lab.id) } returns Optional.of(lab) + + assertThatCode { + service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_ADMIN)) + }.doesNotThrowAnyException() + assertThatThrownBy { + service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT)) + }.isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `lab access follows course`() { + val active = course(Course.STATUS_ACTIVE) + val lab = Lab(number = 1, title = "Лабораторная", course = active) + val groupId = UUID.randomUUID() + every { labs.findById(lab.id) } returns Optional.of(lab) + every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) + every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns + listOf(CourseGroup(course = active, groupId = groupId)) + every { courses.findAllById(any()) } returns listOf(active) + + assertThatCode { + service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT)) + }.doesNotThrowAnyException() + assertThat(service.accessibleCourseIds(jwt(CourseAccessService.ROLE_ADMIN))).isNull() + } + + @Test + fun `my courses admin teacher and student are filtered`() { + val active = course(Course.STATUS_ACTIVE) + val archived = course(Course.STATUS_ARCHIVED) + every { courseService.list() } returns listOf(summary(active), summary(archived)) + + assertThat(service.myCourses(jwt(CourseAccessService.ROLE_ADMIN)).map { it.id }) + .containsExactly(active.id.toString(), archived.id.toString()) + + val teacherId = UUID.randomUUID() + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf( + CourseTeacher(course = active, accountId = teacherId), + CourseTeacher(course = archived, accountId = teacherId), + ) + every { courses.findAllById(any()) } returns listOf(active, archived) + assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, teacherId)).map { it.id }) + .containsExactly(active.id.toString()) + + val groupId = UUID.randomUUID() + every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) + every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns + listOf(CourseGroup(course = active, groupId = groupId)) + every { courses.findAllById(any()) } returns listOf(active) + assertThat(service.myCourses(jwt(CourseAccessService.ROLE_STUDENT)).map { it.id }) + .containsExactly(active.id.toString()) + } + + @Test + fun `empty course list and unknown role are safe`() { + every { teachers.findByAccountIdAndRemovedAtIsNull(any()) } returns emptyList() + every { courseService.list() } returns emptyList() + assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, UUID.randomUUID()))).isEmpty() + assertThat(service.accessibleCourseIds(jwt("UNKNOWN"))).isEmpty() + assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_ADMIN))).isTrue() + assertThat(service.isAdmin(jwt(CourseAccessService.ROLE_TEACHER))).isFalse() + } + + @Test + fun `invalid subject and foreign lab are denied`() { + val invalid = + Jwt + .withTokenValue(TOKEN) + .header("alg", "none") + .subject("not-a-uuid") + .claim("role", "TEACHER") + .build() + assertThatThrownBy { service.accessibleCourseIds(invalid) } + .isInstanceOf(ForbiddenException::class.java) + + val active = course(Course.STATUS_ACTIVE) + val foreign = course(Course.STATUS_ACTIVE) + val teacherId = UUID.randomUUID() + val lab = Lab(number = 1, title = "Лабораторная", course = foreign) + every { labs.findById(lab.id) } returns Optional.of(lab) + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf(CourseTeacher(course = active, accountId = teacherId)) + every { courses.findAllById(any()) } returns listOf(active) + assertThatThrownBy { service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) } + .isInstanceOf(ForbiddenException::class.java) + } + + @Test + fun `missing lab is not found`() { + every { labs.findById(any()) } returns Optional.empty() + assertThatThrownBy { service.requireLabAccess(UUID.randomUUID(), jwt(CourseAccessService.ROLE_ADMIN)) } + .isInstanceOf(ru.ysu.lms.common.NotFoundException::class.java) + } + + private fun course(status: String): Course = Course(title = "Курс", status = status) + + private fun summary(course: Course): CourseSummary = + CourseSummary( + id = course.id.toString(), + courseNo = 1, + title = course.title, + ownerAccountId = course.ownerAccountId.toString(), + status = course.status, + teacherCount = 0, + groupCount = 0, + labCount = 0, + ) + + private fun jwt( + role: String, + subject: UUID = UUID.randomUUID(), + ): Jwt = + Jwt + .withTokenValue(TOKEN) + .header("alg", "none") + .subject(subject.toString()) + .claim("role", role) + .build() + + private companion object { + const val TOKEN = "token" + } +} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServicePropertyTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServicePropertyTest.kt new file mode 100644 index 0000000..1b57090 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServicePropertyTest.kt @@ -0,0 +1,153 @@ +package ru.ysu.lms.academic.course + +import io.mockk.every +import io.mockk.mockk +import net.jqwik.api.ForAll +import net.jqwik.api.Property +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.springframework.security.oauth2.jwt.Jwt +import ru.ysu.lms.academic.domain.Course +import ru.ysu.lms.academic.domain.CourseGroup +import ru.ysu.lms.academic.domain.CourseTeacher +import ru.ysu.lms.academic.identity.IdentityClient +import ru.ysu.lms.academic.repo.CourseGroupRepository +import ru.ysu.lms.academic.repo.CourseRepository +import ru.ysu.lms.academic.repo.CourseTeacherRepository +import ru.ysu.lms.academic.repo.LabRepository +import ru.ysu.lms.common.ForbiddenException +import java.util.UUID + +/** + * Property-based проверка матрицы доступа: не отдельные примеры, а + * утверждение «доступ есть ровно тогда, когда выполнены все условия». + * jqwik перебирает комбинации флагов; PIT дополнительно проверяет, что + * тест падает при мутации логики. + */ +class CourseAccessServicePropertyTest { + @Suppress("ktlint:standard:function-naming") + @Property(tries = 80) + fun `teacher access equals assigned and active course`( + @ForAll hasAssignment: Boolean, + @ForAll targetAssigned: Boolean, + @ForAll active: Boolean, + ) { + val courses = mockk() + val teachers = mockk() + val groups = mockk() + val labs = mockk() + val identity = mockk() + val service = + CourseAccessService( + courses, + teachers, + groups, + labs, + identity, + mockk(), + ) + + val teacherId = UUID.randomUUID() + val assigned = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED) + val target = if (targetAssigned) assigned else course(Course.STATUS_ACTIVE) + if (hasAssignment) { + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns + listOf(CourseTeacher(course = assigned, accountId = teacherId)) + every { courses.findAllById(any()) } returns listOf(assigned) + } else { + every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList() + } + + val thrown = + catchThrowable { + service.requireCourseAccess(target.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId)) + } + val expected = hasAssignment && targetAssigned && active + if (expected) { + assertThat(thrown).isNull() + } else { + assertThat(thrown).isInstanceOf(ForbiddenException::class.java) + } + } + + @Suppress("ktlint:standard:function-naming") + @Property(tries = 80) + fun `student access equals current attached active course`( + @ForAll hasGroup: Boolean, + @ForAll attached: Boolean, + @ForAll active: Boolean, + ) { + val courses = mockk() + val teachers = mockk() + val groups = mockk() + val labs = mockk() + val identity = mockk() + val service = + CourseAccessService( + courses, + teachers, + groups, + labs, + identity, + mockk(), + ) + + val groupId = UUID.randomUUID() + val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED) + if (hasGroup) { + every { identity.currentGroupIds(TOKEN) } returns listOf(groupId) + every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns + if (attached) listOf(CourseGroup(course = course, groupId = groupId)) else emptyList() + every { courses.findAllById(any()) } returns listOf(course) + } else { + every { identity.currentGroupIds(TOKEN) } returns emptyList() + } + + val thrown = + catchThrowable { + service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_STUDENT)) + } + val expected = hasGroup && attached && active + if (expected) { + assertThat(thrown).isNull() + } else { + assertThat(thrown).isInstanceOf(ForbiddenException::class.java) + } + } + + @Suppress("ktlint:standard:function-naming") + @Property(tries = 20) + fun `admin always bypasses course checks`( + @ForAll active: Boolean, + ) { + val service = + CourseAccessService( + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + ) + val course = course(if (active) Course.STATUS_ACTIVE else Course.STATUS_ARCHIVED) + + assertThat(catchThrowable { service.requireCourseAccess(course.id, jwt(CourseAccessService.ROLE_ADMIN)) }).isNull() + } + + private fun course(status: String): Course = Course(title = "Курс", status = status) + + private fun jwt( + role: String, + subject: UUID = UUID.randomUUID(), + ): Jwt = + Jwt + .withTokenValue(TOKEN) + .header("alg", "none") + .subject(subject.toString()) + .claim("role", role) + .build() + + private companion object { + const val TOKEN = "token" + } +} diff --git a/services/identity-service/build.gradle.kts b/services/identity-service/build.gradle.kts index 26fe4b3..d52f369 100644 --- a/services/identity-service/build.gradle.kts +++ b/services/identity-service/build.gradle.kts @@ -2,6 +2,57 @@ plugins { id("lms.spring-boot") } +/** PIT через CLI: Gradle-плагин 1.15 несовместим с Gradle 9.6, JUnit5-плагин — с JUnit Platform 6. */ +val pitest by configurations.creating + +dependencies { + add("pitest", libs.pitestCommandLine) +} + +tasks.register("pitestPromotionPlanner") { + group = "verification" + description = "Mutation testing PromotionPlanner (P0 promotion rules)" + dependsOn(tasks.testClasses) + classpath = pitest + sourceSets.getByName("test").runtimeClasspath + mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport") + val reportDir = + layout.buildDirectory + .dir("reports/pitest-promotion-planner") + .get() + .asFile + .absolutePath + val sourceDirs = + sourceSets.main + .get() + .kotlin.srcDirs + .joinToString(",") { it.absolutePath } + val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath + args( + "--reportDir", + reportDir, + "--targetClasses", + "ru.ysu.lms.identity.promotion.PromotionPlanner", + "--targetTests", + "ru.ysu.lms.identity.promotion.PromotionPlannerMutationTest", + "--sourceDirs", + sourceDirs, + "--classPath", + testClasspath, + "--mutators", + "STRONGER", + "--threads", + "1", + "--outputFormats", + "XML,HTML", + "--timestampedReports", + "false", + "--timeoutConst", + "15000", + "--failWhenNoMutations", + "true", + ) +} + dependencies { implementation(platform(libs.springBootBom)) @@ -15,6 +66,8 @@ dependencies { implementation(libs.flywayPostgresql) runtimeOnly(libs.postgresql) implementation(libs.jacksonYaml) + implementation(libs.jacksonModuleKotlin) + implementation(libs.jackson3ModuleKotlin) implementation(libs.springdocStarterWebmvcUi) implementation(libs.apachePoiOoxml) @@ -24,6 +77,8 @@ dependencies { testImplementation(project(":libs:test-support")) testImplementation(libs.springBootStarterTest) testImplementation(libs.springSecurityTest) + // PIT JUnit5-плагин несовместим с JUnit Platform 6; mutation harness — JUnit 4. + testImplementation("junit:junit:4.13.2") } springBoot { diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerMutationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerMutationTest.kt new file mode 100644 index 0000000..5f2a460 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerMutationTest.kt @@ -0,0 +1,248 @@ +package ru.ysu.lms.identity.promotion + +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import ru.ysu.lms.identity.domain.Enrollment +import ru.ysu.lms.identity.domain.GroupEntity +import ru.ysu.lms.identity.domain.GroupStatus +import ru.ysu.lms.identity.domain.PromotionAction +import ru.ysu.lms.identity.domain.PromotionEntityType +import ru.ysu.lms.identity.domain.Student +import ru.ysu.lms.identity.domain.StudentStatus +import ru.ysu.lms.identity.repo.EnrollmentRepository +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.StudentRepository +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import kotlin.random.Random + +/** JUnit 4 harness для PIT; обычный `test` его не запускает. */ +class PromotionPlannerMutationTest { + private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC) + + @Test + fun `group promotion and graduation follow limits`() { + val random = Random(17) + repeat(25) { + val groups = (1..random.nextInt(1, 6)).map { index -> group(random, index) } + val planner = planner(groups) + val plan = planner.plan(2026) + val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId } + + for (group in groups.filter { it.status == GroupStatus.ACTIVE }) { + val item = items.getValue(group.id) + val course = group.course!!.toInt() + val limit = if (group.degree == "БО") 4 else 2 + assertThat(item.before["course"]).isEqualTo(course) + if (course < limit) { + assertThat(item.action).isEqualTo(PromotionAction.PROMOTE) + assertThat(item.after["course"]).isEqualTo(course + 1) + assertThat(item.after["name"]) + .isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}") + } else { + assertThat(item.action).isEqualTo(PromotionAction.GRADUATE) + assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name) + } + } + } + } + + @Test + fun `summary counts graduates academic moves and warnings`() { + val promote = group(index = 1, course = 1) + val graduate = group(index = 2, course = 4) + val inactive = group(index = 3, course = 1, status = GroupStatus.ARCHIVED) + val invalid = group(index = 4, course = 1, degree = "XX") + + val activeStudent = student(StudentStatus.ACTIVE) + val academicStudent = student(StudentStatus.ACADEMIC_LEAVE) + val expelledStudent = student(StudentStatus.EXPELLED) + val enrollments = + listOf( + Enrollment(group = graduate, student = activeStudent), + Enrollment(group = promote, student = academicStudent), + Enrollment(group = promote, student = expelledStudent), + ) + val planner = + planner( + groups = listOf(promote, graduate, inactive, invalid), + students = listOf(activeStudent, academicStudent, expelledStudent), + enrollments = enrollments, + ) + + val plan = planner.plan(2026) + + @Suppress("UNCHECKED_CAST") + val counts = plan.result["counts"] as Map + assertThat(counts["groupsPromote"]).isEqualTo(1) + assertThat(counts["groupsGraduate"]).isEqualTo(1) + assertThat(counts["studentsGraduate"]).isEqualTo(1) + assertThat(counts["studentsAcademic"]).isEqualTo(1) + @Suppress("UNCHECKED_CAST") + val warnings = plan.result["warnings"] as List + assertThat(warnings).anyMatch { it.contains("некорректны") } + + val graduateItem = plan.items.first { it.entityId == graduate.id } + assertThat(graduateItem.before).containsEntry("status", GroupStatus.ACTIVE.name) + assertThat(graduateItem.after).containsEntry("status", GroupStatus.GRADUATED.name) + val activeItem = plan.items.first { it.entityId == activeStudent.accountId } + assertThat(activeItem.after).containsEntry("status", StudentStatus.GRADUATED.name) + assertThat(plan.items.map { it.entityId }).doesNotContain(inactive.id) + } + + @Test + fun `academic leave uses existing target and skips incomplete sources`() { + val source = group(index = 5, course = 2) + val target = group(index = 6, course = 2) + val student = student(StudentStatus.ACADEMIC_LEAVE) + val enrollment = Enrollment(group = source, student = student) + val planner = planner(listOf(source, target), listOf(student), listOf(enrollment), existingTarget = target) + + val plan = planner.plan(2026) + val item = plan.items.first { it.entityId == student.accountId } + + @Suppress("UNCHECKED_CAST") + val targetState = item.after["targetGroup"] as Map + assertThat(targetState["id"]).isEqualTo(target.id.toString()) + assertThat(targetState["created"]).isEqualTo(false) + + val noEnrollment = student(StudentStatus.ACADEMIC_LEAVE) + val noEnrollmentPlan = planner(listOf(source), listOf(noEnrollment)).plan(2026) + assertThat(noEnrollmentPlan.items.map { it.entityId }).doesNotContain(noEnrollment.accountId) + @Suppress("UNCHECKED_CAST") + val warnings = noEnrollmentPlan.result["warnings"] as List + assertThat(warnings).anyMatch { it.contains("нет зачисления") } + + val incomplete = group(index = 7, course = 1, stream = null) + val incompleteStudent = student(StudentStatus.ACADEMIC_LEAVE) + val incompletePlan = + planner( + listOf(incomplete), + listOf(incompleteStudent), + listOf(Enrollment(group = incomplete, student = incompleteStudent)), + ).plan(2026) + assertThat(incompletePlan.items.map { it.entityId }).doesNotContain(incompleteStudent.accountId) + } + + @Test + fun `active student outside graduating group is not moved`() { + val group = group(index = 8, course = 1) + val student = student(StudentStatus.ACTIVE) + val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student))) + val plan = planner.plan(2026) + assertThat(plan.items.map { it.entityId }).doesNotContain(student.accountId) + } + + @Test + fun `enrollment without student does not break planning`() { + val group = group(index = 9, course = 1) + val student = student(StudentStatus.ACTIVE) + val orphanEnrollment = Enrollment(group = group, student = null) + val planner = planner(listOf(group), listOf(student), listOf(orphanEnrollment)) + val plan = planner.plan(2026) + assertThat(plan.items).isNotEmpty + } + + @Test + fun `academic student moves to same course of previous cohort`() { + val random = Random(18) + repeat(20) { + val group = group(random, 1, forceActive = true) + val student = + Student( + accountId = UUID.randomUUID(), + studentNo = "S-${random.nextInt()}", + lastName = "Иванов", + firstName = "Иван", + status = StudentStatus.ACADEMIC_LEAVE, + ) + val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student))) + val plan = planner.plan(2026) + val item = + plan.items.first { + it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId + } + val course = group.course!!.toInt() + assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC) + assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1)) + @Suppress("UNCHECKED_CAST") + val target = item.after["targetGroup"] as Map + assertThat(target["course"]).isEqualTo(course) + assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1)) + } + } + + private fun group( + random: Random, + index: Int, + forceActive: Boolean = false, + ): GroupEntity { + val degree = if (random.nextBoolean()) "БО" else "МО" + val limit = if (degree == "БО") 4 else 2 + return GroupEntity( + id = UUID.randomUUID(), + name = "ПИ-${index}$degree", + stream = "ПИ", + course = random.nextInt(1, limit + 1).toShort(), + number = index.toShort(), + degree = degree, + enrollmentYear = 2026, + status = if (forceActive || random.nextBoolean()) GroupStatus.ACTIVE else GroupStatus.ARCHIVED, + ) + } + + private fun group( + index: Int, + course: Int, + degree: String = "БО", + status: GroupStatus = GroupStatus.ACTIVE, + stream: String? = "ПИ", + ): GroupEntity = + GroupEntity( + id = UUID.randomUUID(), + name = "ПИ-${index}$degree", + stream = stream, + course = course.toShort(), + number = index.toShort(), + degree = degree, + enrollmentYear = 2026, + status = status, + ) + + private fun student(status: StudentStatus): Student = + Student( + accountId = UUID.randomUUID(), + studentNo = "S-${UUID.randomUUID()}", + lastName = "Иванов", + firstName = "Иван", + status = status, + ) + + private fun planner( + groups: List, + students: List = emptyList(), + enrollments: List = emptyList(), + existingTarget: GroupEntity? = null, + ): PromotionPlanner { + val groupRepository = mockk() + val studentRepository = mockk() + val enrollmentRepository = mockk() + every { groupRepository.findAll() } returns groups + every { studentRepository.findAll() } returns students + every { enrollmentRepository.findAllWithStudentAndGroup() } returns enrollments + every { + groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear( + any(), + any(), + any(), + any(), + any(), + ) + } returns existingTarget + return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock) + } +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerPropertyTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerPropertyTest.kt new file mode 100644 index 0000000..fb44c35 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/promotion/PromotionPlannerPropertyTest.kt @@ -0,0 +1,145 @@ +package ru.ysu.lms.identity.promotion + +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import ru.ysu.lms.identity.domain.Enrollment +import ru.ysu.lms.identity.domain.GroupEntity +import ru.ysu.lms.identity.domain.GroupStatus +import ru.ysu.lms.identity.domain.PromotionAction +import ru.ysu.lms.identity.domain.PromotionEntityType +import ru.ysu.lms.identity.domain.Student +import ru.ysu.lms.identity.domain.StudentStatus +import ru.ysu.lms.identity.repo.EnrollmentRepository +import ru.ysu.lms.identity.repo.GroupRepository +import ru.ysu.lms.identity.repo.StudentRepository +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import kotlin.random.Random + +/** + * Property-style проверки планировщика повышения: не один сценарий, а + * 100 сгенерированных когорт. Проверяются инварианты «курс + 1», выпуск на + * пределе и академический перевод в группу того же курса с годом набора + * `year - (course - 1)`. + */ +class PromotionPlannerPropertyTest { + private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC) + + @Test + fun `active groups promote or graduate exactly by degree limit`() { + val random = Random(20261027) + repeat(100) { iteration -> + val groups = (1..random.nextInt(1, 8)).map { index -> randomGroup(random, index) } + val planner = planner(groups = groups) + + val plan = planner.plan(2026) + val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId } + + val active = groups.filter { it.status == GroupStatus.ACTIVE } + assertThat(items).hasSize(active.size) + + for (group in active) { + val item = items.getValue(group.id) + val course = group.course!!.toInt() + val limit = if (group.degree == "БО") 4 else 2 + assertThat(item.before["course"]).isEqualTo(course) + if (course < limit) { + assertThat(item.action).isEqualTo(PromotionAction.PROMOTE) + assertThat(item.after["course"]).isEqualTo(course + 1) + assertThat(item.after["name"]) + .isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}") + } else { + assertThat(item.action).isEqualTo(PromotionAction.GRADUATE) + assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name) + } + } + + val inactive = groups.filter { it.status != GroupStatus.ACTIVE }.map { it.id } + if (inactive.isNotEmpty()) { + assertThat(items.keys).doesNotContainAnyElementsOf(inactive) + } + assertThat(plan.result["counts"]).isNotNull + assertThat(plan.items.size).isEqualTo(items.size) + } + } + + @Test + fun `academic leave targets the same course with year minus course minus one`() { + val random = Random(20261028) + repeat(100) { iteration -> + val group = randomGroup(random, 1, forceActive = true) + val student = + Student( + accountId = UUID.randomUUID(), + studentNo = "S-$iteration-${random.nextInt()}", + lastName = "Иванов", + firstName = "Иван", + status = StudentStatus.ACADEMIC_LEAVE, + ) + val planner = + planner(groups = listOf(group), students = listOf(student), enrollment = Enrollment(group = group, student = student)) + + val plan = planner.plan(2026) + val item = + plan.items.first { + it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId + } + val course = group.course!!.toInt() + + assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC) + assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1)) + @Suppress("UNCHECKED_CAST") + val target = item.after["targetGroup"] as Map + assertThat(target["course"]).isEqualTo(course) + assertThat(target["stream"]).isEqualTo(group.stream) + assertThat(target["degree"]).isEqualTo(group.degree) + assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1)) + } + } + + private fun randomGroup( + random: Random, + index: Int, + forceActive: Boolean = false, + ): GroupEntity { + val degree = if (random.nextBoolean()) "БО" else "МО" + val limit = if (degree == "БО") 4 else 2 + return GroupEntity( + id = UUID.randomUUID(), + name = "ПИ-${random.nextInt(1, limit + 1)}${index}$degree", + stream = "ПИ", + course = random.nextInt(1, limit + 1).toShort(), + number = index.toShort(), + degree = degree, + enrollmentYear = 2026, + status = if (forceActive || random.nextInt(5) > 0) GroupStatus.ACTIVE else GroupStatus.ARCHIVED, + ) + } + + private fun planner( + groups: List, + students: List = emptyList(), + enrollment: Enrollment? = null, + ): PromotionPlanner { + val groupRepository = mockk() + val studentRepository = mockk() + val enrollmentRepository = mockk() + every { groupRepository.findAll() } returns groups + every { studentRepository.findAll() } returns students + every { enrollmentRepository.findAllWithStudentAndGroup() } returns listOfNotNull(enrollment) + every { + groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear( + any(), + any(), + any(), + any(), + any(), + ) + } returns null + return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock) + } +} diff --git a/services/progress-service/build.gradle.kts b/services/progress-service/build.gradle.kts index 47f4bd1..262bf0d 100644 --- a/services/progress-service/build.gradle.kts +++ b/services/progress-service/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(libs.flywayPostgresql) runtimeOnly(libs.postgresql) implementation(libs.springdocStarterWebmvcUi) + implementation(libs.jacksonModuleKotlin) + implementation(libs.jackson3ModuleKotlin) implementation(project(":libs:common-http")) implementation(project(":libs:security-jwt")) diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 097a33d..6cceb6d 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { implementation(libs.springBootStarterThymeleaf) implementation(libs.springBootStarterValidation) implementation(libs.springBootStarterActuator) + implementation(libs.jacksonModuleKotlin) + implementation(libs.jackson3ModuleKotlin) implementation(project(":libs:common-http")) implementation(project(":libs:markdown"))