feat(ldd): property-based, mutation и браузерный E2E
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
# Каталог с боевыми данными и паролями (не коммитить!)
|
# Каталог с боевыми данными и паролями (не коммитить!)
|
||||||
|
|||||||
@@ -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
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"
|
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" }
|
||||||
|
|||||||
@@ -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
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")
|
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 {
|
||||||
|
|||||||
@@ -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")
|
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 {
|
||||||
|
|||||||
@@ -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)
|
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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user