feat(ldd): property-based, mutation и браузерный E2E
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ build/
|
||||
/exports/
|
||||
/texput.log
|
||||
node_modules/
|
||||
.jqwik-database
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
# Локальные сессии Kilo Code — не часть репозитория.
|
||||
.kilo/
|
||||
# Каталог с боевыми данными и паролями (не коммитить!)
|
||||
|
||||
@@ -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, один сценарий на роль; визуальная регрессия и мобильные размеры не проверяются."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
188
e2e/lms.spec.ts
Normal file
188
e2e/lms.spec.ts
Normal 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
58
e2e/package-lock.json
generated
Normal 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
7
e2e/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "lms-e2e",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1"
|
||||
}
|
||||
}
|
||||
27
e2e/playwright.config.ts
Normal file
27
e2e/playwright.config.ts
Normal 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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
113
scripts/ldd-browser.sh
Executable file
113
scripts/ldd-browser.sh
Executable 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
68
scripts/ldd-mutation.sh
Executable 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
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user