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

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

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

@@ -0,0 +1,188 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
const apiBase = process.env.LDD_API_URL ?? "http://localhost:8080";
type Seeded = {
courseId: string;
courseNo: number;
courseTitle: string;
groupId: string;
groupName: string;
studentFullName: string;
studentLogin: string;
studentPassword: string;
teacherLogin: string;
teacherPassword: string;
adminLogin: string;
adminPassword: string;
};
let seeded: Seeded;
let api: APIRequestContext;
async function expectOk(response: Awaited<ReturnType<APIRequestContext["post"]>>): Promise<unknown> {
if (!response.ok()) {
throw new Error(`${response.url()} -> ${response.status()}: ${await response.text()}`);
}
return response.json();
}
async function setupAdmin(context: APIRequestContext): Promise<string> {
const health = await context.get("/api/v1/auth/health");
const healthBody = (await health.json()) as { adminExists: boolean };
if (healthBody.adminExists) {
const login = await context.post("/api/v1/auth/login", {
data: { login: "e2e-admin", password: "e2e-admin-123" },
});
return ((await expectOk(login)) as { token: string }).token;
}
const setup = await context.post("/api/v1/auth/setup", {
data: { login: "e2e-admin", password: "e2e-admin-123", password2: "e2e-admin-123" },
});
return ((await expectOk(setup)) as { token: string }).token;
}
async function createWithToken(
context: APIRequestContext,
token: string,
method: "post",
path: string,
data: unknown,
): Promise<Record<string, unknown>> {
const response = await context[method](path, {
data,
headers: { Authorization: `Bearer ${token}` },
});
return (await expectOk(response)) as Record<string, unknown>;
}
async function changePassword(context: APIRequestContext, login: string, oldPassword: string, newPassword: string) {
const loginResponse = await context.post("/api/v1/auth/login", {
data: { login, password: oldPassword },
});
const token = ((await expectOk(loginResponse)) as { token: string }).token;
await expectOk(
await context.post("/api/v1/auth/change-password", {
headers: { Authorization: `Bearer ${token}` },
data: { old: oldPassword, new: newPassword, new2: newPassword },
}),
);
}
async function login(page: Page, login: string, password: string, expectedPath: RegExp | string) {
await page.goto("/login");
await page.fill("#login", login);
await page.fill("#password", password);
await Promise.all([page.waitForURL(expectedPath), page.click('button[type="submit"]')]);
}
test.beforeAll(async ({ playwright }) => {
api = await playwright.request.newContext({ baseURL: apiBase });
const suffix = Math.random().toString(36).slice(2, 8);
const adminToken = await setupAdmin(api);
const group = await createWithToken(api, adminToken, "post", "/api/v1/groups", {
stream: "E2E" + suffix,
course: 1,
number: 1,
degree: "БО",
enrollmentYear: 2026,
});
const course = await createWithToken(api, adminToken, "post", "/api/v1/courses", {
title: "E2E Course " + suffix,
});
const teacher = await createWithToken(api, adminToken, "post", "/api/v1/teachers", {
login: "e2e-teacher-" + suffix,
password: "e2e-teacher-123",
});
const teacherLogin = String(teacher.login);
await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/teachers`, {
accountId: teacher.id,
});
await createWithToken(api, adminToken, "post", `/api/v1/courses/${course.id}/groups`, {
groupId: group.id,
});
await createWithToken(api, adminToken, "post", "/api/v1/labs", {
number: 1,
title: "E2E Lab",
status: "published",
courseId: course.id,
levels: [{ code: "basic", title: "Базовый", position: 1 }],
});
const studentNo = "E2E-S-" + suffix;
const student = await createWithToken(api, adminToken, "post", `/api/v1/groups/${group.id}/students`, {
studentNo,
lastName: "Тестов",
firstName: "Студент",
basis: "бюджет",
});
const studentPassword = String(student.password);
await changePassword(api, teacherLogin, "e2e-teacher-123", "e2e-teacher-456");
await changePassword(api, studentNo, studentPassword, "e2e-student-456");
seeded = {
courseId: String(course.id),
courseNo: Number(course.courseNo),
courseTitle: String(course.title),
groupId: String(group.id),
groupName: String(group.name),
studentFullName: String(student.fullName),
studentLogin: studentNo,
studentPassword: "e2e-student-456",
teacherLogin,
teacherPassword: "e2e-teacher-456",
adminLogin: "e2e-admin",
adminPassword: "e2e-admin-123",
};
});
test.afterAll(async () => {
await api.dispose();
});
test("admin opens courses and audit", async ({ page }) => {
await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin");
await page.goto("/admin/courses");
await expect(page.locator("body")).toContainText(seeded.courseTitle);
await page.goto("/admin/audit");
await expect(page.locator("body")).toContainText("Создан курс");
});
test("teacher opens course, gradebook, student profile and authors a chapter", async ({ page }) => {
await login(page, seeded.teacherLogin, seeded.teacherPassword, "**/teacher");
await expect(page.locator("body")).toContainText(seeded.courseTitle);
await page.goto(`/teacher/courses/${seeded.courseId}`);
await expect(page.locator("h1")).toContainText(seeded.courseTitle);
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText("E2E Lab");
await page.getByRole("link", { name: "Ведомость" }).click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${seeded.groupId}\\?courseId=`));
await expect(page.locator("body")).toContainText(seeded.groupName);
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await expect(page.getByRole("button", { name: "Отчислить" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Сбросить" })).toHaveCount(0);
await page.getByRole("link", { name: seeded.studentFullName }).first().click();
await expect(page).toHaveURL(new RegExp(`/teacher/courses/${seeded.courseId}/students/`));
await expect(page.locator("body")).toContainText(seeded.studentFullName);
await page.goto(`/teacher/courses/${seeded.courseId}/content`);
await page.fill("#chapterTitle", "E2E Chapter");
await page.getByRole("button", { name: "Создать главу" }).click();
await expect(page.locator("body")).toContainText("E2E Chapter");
});
test("anonymous and student cannot reach admin pages", async ({ page }) => {
await page.goto("/admin/audit");
await expect(page).toHaveURL(/\/login/);
await login(page, seeded.studentLogin, seeded.studentPassword, "**/student");
await page.goto("/admin/audit");
await expect(page).toHaveURL(/\/student/);
await page.goto(`/learn/course/${seeded.courseNo}`);
await expect(page.locator("body")).toContainText(seeded.courseTitle);
});

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

@@ -0,0 +1,58 @@
{
"name": "lms-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lms-e2e",
"devDependencies": {
"@playwright/test": "1.64.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

7
e2e/package.json Normal file
View File

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

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

@@ -0,0 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: ".",
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
forbidOnly: true,
reporter: [
["list"],
["json", { outputFile: "../build/evidence/playwright.json" }],
],
use: {
baseURL: process.env.LDD_WEB_URL ?? "http://localhost:8090",
headless: true,
ignoreHTTPSErrors: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});