feat(ldd): property-based, mutation и браузерный E2E

This commit is contained in:
2026-10-28 00:01:56 +03:00
parent 80ccf26b61
commit 905feb440b
18 changed files with 1432 additions and 2 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ build/
/exports/ /exports/
/texput.log /texput.log
node_modules/ node_modules/
.jqwik-database
e2e/test-results/
e2e/playwright-report/
# Локальные сессии Kilo Code — не часть репозитория. # Локальные сессии Kilo Code — не часть репозитория.
.kilo/ .kilo/
# Каталог с боевыми данными и паролями (не коммитить!) # Каталог с боевыми данными и паролями (не коммитить!)

View File

@@ -394,7 +394,8 @@
"ru.ysu.lms.web.profile.ProfileDtoJsonTest#deserializes profile contract()", "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.CourseGroupRosterDtoTest#deserializes course group roster contract()",
"ru.ysu.lms.web.api.AuditDtoTest#deserializes audit event list with nulls()", "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." "limitations": "Это smoke-контракты, не consumer-driven contracts."
}, },
@@ -458,6 +459,45 @@
"runtime.web_login_page" "runtime.web_login_page"
], ],
"limitations": "Smoke не заменяет полный UI E2E и нагрузочные тесты." "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, один сценарий на роль; визуальная регрессия и мобильные размеры не проверяются."
} }
] ]
} }

188
e2e/lms.spec.ts Normal file
View File

@@ -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<ReturnType<APIRequestContext["post"]>>): Promise<unknown> {
if (!response.ok()) {
throw new Error(`${response.url()} -> ${response.status()}: ${await response.text()}`);
}
return response.json();
}
async function setupAdmin(context: APIRequestContext): Promise<string> {
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<Record<string, unknown>> {
const response = await context[method](path, {
data,
headers: { Authorization: `Bearer ${token}` },
});
return (await expectOk(response)) as Record<string, unknown>;
}
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);
});

58
e2e/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

7
e2e/package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "lms-e2e",
"private": true,
"devDependencies": {
"@playwright/test": "1.64.1"
}
}

27
e2e/playwright.config.ts Normal file
View File

@@ -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"] },
},
],
});

View File

@@ -13,6 +13,9 @@ apachePoi = "5.5.1"
ktlint = "14.2.0" ktlint = "14.2.0"
detekt = "1.23.8" detekt = "1.23.8"
mockk = "1.14.11" mockk = "1.14.11"
jqwik = "1.9.3"
pitest = "1.15.0"
pitestJunit5 = "1.2.1"
[libraries] [libraries]
# --- Gradle plugins used from buildSrc --- # --- Gradle plugins used from buildSrc ---
@@ -58,6 +61,7 @@ flywayPostgresql = { module = "org.flywaydb:flyway-database-postgresql" }
postgresql = { module = "org.postgresql:postgresql" } postgresql = { module = "org.postgresql:postgresql" }
jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" } jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" }
jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" } jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
jackson3ModuleKotlin = { module = "tools.jackson.module:jackson-module-kotlin", version = "3.1.5" }
# --- API docs --- # --- API docs ---
springdocStarterWebmvcUi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } 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) --- # --- Tests / Testcontainers (versions managed by the Boot BOM) ---
mockk = { module = "io.mockk:mockk", version.ref = "mockk" } 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" } junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" } testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }

View File

@@ -11,6 +11,7 @@ dependencies {
api(libs.testcontainersJunitJupiter) api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest) api(libs.springBootStarterTest)
api(libs.mockk) api(libs.mockk)
api(libs.jqwik)
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно. // Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
api(libs.junitPlatformLauncher) api(libs.junitPlatformLauncher)
} }

113
scripts/ldd-browser.sh Executable file
View File

@@ -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

68
scripts/ldd-mutation.sh Executable file
View File

@@ -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

View File

@@ -2,6 +2,58 @@ plugins {
id("lms.spring-boot") id("lms.spring-boot")
} }
/** PIT запускается отдельной задачей: Gradle-плагин 1.15 несовместим с Gradle 9.6. */
val pitest by configurations.creating
dependencies {
add("pitest", libs.pitestCommandLine)
}
tasks.register<JavaExec>("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 { dependencies {
implementation(platform(libs.springBootBom)) implementation(platform(libs.springBootBom))
@@ -16,6 +68,7 @@ dependencies {
runtimeOnly(libs.postgresql) runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml) implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin) implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi) implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http")) implementation(project(":libs:common-http"))
@@ -24,6 +77,9 @@ dependencies {
testImplementation(project(":libs:test-support")) testImplementation(project(":libs:test-support"))
testImplementation(libs.springSecurityTest) 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 { springBoot {

View File

@@ -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<CourseRepository>()
private val teachers = mockk<CourseTeacherRepository>()
private val groups = mockk<CourseGroupRepository>()
private val labs = mockk<LabRepository>()
private val identity = mockk<IdentityClient>()
private val courseService = mockk<CourseService>()
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"
}
}

View File

@@ -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<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
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<CourseRepository>()
val teachers = mockk<CourseTeacherRepository>()
val groups = mockk<CourseGroupRepository>()
val labs = mockk<LabRepository>()
val identity = mockk<IdentityClient>()
val service =
CourseAccessService(
courses,
teachers,
groups,
labs,
identity,
mockk<CourseService>(),
)
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"
}
}

View File

@@ -2,6 +2,57 @@ plugins {
id("lms.spring-boot") 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<JavaExec>("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 { dependencies {
implementation(platform(libs.springBootBom)) implementation(platform(libs.springBootBom))
@@ -15,6 +66,8 @@ dependencies {
implementation(libs.flywayPostgresql) implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql) runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml) implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi) implementation(libs.springdocStarterWebmvcUi)
implementation(libs.apachePoiOoxml) implementation(libs.apachePoiOoxml)
@@ -24,6 +77,8 @@ dependencies {
testImplementation(project(":libs:test-support")) testImplementation(project(":libs:test-support"))
testImplementation(libs.springBootStarterTest) testImplementation(libs.springBootStarterTest)
testImplementation(libs.springSecurityTest) testImplementation(libs.springSecurityTest)
// PIT JUnit5-плагин несовместим с JUnit Platform 6; mutation harness — JUnit 4.
testImplementation("junit:junit:4.13.2")
} }
springBoot { springBoot {

View File

@@ -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<String, Any?>
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<String>
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<String, Any?>
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<String>
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<String, Any?>
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<GroupEntity>,
students: List<Student> = emptyList(),
enrollments: List<Enrollment> = emptyList(),
existingTarget: GroupEntity? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
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)
}
}

View File

@@ -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<String, Any?>
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<GroupEntity>,
students: List<Student> = emptyList(),
enrollment: Enrollment? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
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)
}
}

View File

@@ -15,6 +15,8 @@ dependencies {
implementation(libs.flywayPostgresql) implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql) runtimeOnly(libs.postgresql)
implementation(libs.springdocStarterWebmvcUi) implementation(libs.springdocStarterWebmvcUi)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(project(":libs:common-http")) implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt")) implementation(project(":libs:security-jwt"))

View File

@@ -9,6 +9,8 @@ dependencies {
implementation(libs.springBootStarterThymeleaf) implementation(libs.springBootStarterThymeleaf)
implementation(libs.springBootStarterValidation) implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator) implementation(libs.springBootStarterActuator)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(project(":libs:common-http")) implementation(project(":libs:common-http"))
implementation(project(":libs:markdown")) implementation(project(":libs:markdown"))