Law/e2e/tests/helpers.js
2026-03-31 13:56:11 +03:00

427 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const path = require("path");
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
const { expect } = require("@playwright/test");
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
function randomDigits(length) {
let value = "";
while (value.length < length) {
value += String(Math.floor(Math.random() * 10));
}
return value.slice(0, length);
}
function randomPhone() {
return `+79${randomDigits(9)}`;
}
function buildTinyPdfBuffer(label = "E2E PDF") {
const safe = String(label || "E2E PDF").replace(/[()\\]/g, " ");
const stream = `BT /F1 16 Tf 24 96 Td (${safe}) Tj ET`;
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
`<< /Length ${Buffer.byteLength(stream, "utf-8")} >>\nstream\n${stream}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (let i = 0; i < objects.length; i += 1) {
offsets.push(Buffer.byteLength(pdf, "utf-8"));
pdf += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
}
const xrefOffset = Buffer.byteLength(pdf, "utf-8");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (let i = 1; i < offsets.length; i += 1) {
pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Root 1 0 R /Size ${objects.length + 1} >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "utf-8");
}
function detectMimeForFixture(fileName) {
const lower = String(fileName || "").toLowerCase();
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
return "application/octet-stream";
}
function createPublicCookieToken(phone) {
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
algorithm: "HS256",
expiresIn: "7d",
});
}
function createPublicViewCookieToken(subject) {
return jwt.sign({ sub: subject, purpose: "VIEW_REQUEST" }, PUBLIC_SECRET, {
algorithm: "HS256",
expiresIn: "7d",
});
}
function createCleanupTracker() {
const state = {
track_numbers: new Set(),
phones: new Set(),
emails: new Set(),
hasArtifacts: false,
};
return {
addTrack(value) {
const text = String(value || "").trim();
if (!text) return;
state.track_numbers.add(text);
state.hasArtifacts = true;
},
addPhone(value) {
const text = String(value || "").trim();
if (!text) return;
state.phones.add(text);
state.hasArtifacts = true;
},
addEmail(value) {
const text = String(value || "").trim().toLowerCase();
if (!text) return;
state.emails.add(text);
state.hasArtifacts = true;
},
hasArtifacts() {
return state.hasArtifacts;
},
toPayload() {
return {
track_numbers: Array.from(state.track_numbers),
phones: Array.from(state.phones),
emails: Array.from(state.emails),
include_default_e2e_patterns: true,
};
},
};
}
function _getCleanupTracker(testInfo) {
if (!testInfo) return null;
if (!testInfo._cleanupTracker) {
testInfo._cleanupTracker = createCleanupTracker();
}
return testInfo._cleanupTracker;
}
function trackCleanupPhone(testInfo, phone) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addPhone(phone);
}
function trackCleanupTrack(testInfo, trackNumber) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addTrack(trackNumber);
}
function trackCleanupEmail(testInfo, email) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addEmail(email);
}
async function cleanupTrackedTestData(page, testInfo) {
const tracker = testInfo && testInfo._cleanupTracker;
if (!tracker || !tracker.hasArtifacts()) {
return;
}
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
let token = "";
const loginResponse = await page.request.post(`${baseUrl}/api/admin/auth/login`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
failOnStatusCode: false,
});
if (loginResponse.ok()) {
const body = await loginResponse.json().catch(() => ({}));
token = String(body?.access_token || "");
}
if (!token) {
throw new Error(`E2E cleanup failed: admin login ${loginResponse.status()}`);
}
const cleanupResponse = await page.request.post(`${baseUrl}/api/admin/test-utils/cleanup-test-data`, {
headers: { Authorization: `Bearer ${token}` },
data: tracker.toPayload(),
failOnStatusCode: false,
});
if (!cleanupResponse.ok()) {
const text = await cleanupResponse.text().catch(() => "");
throw new Error(`E2E cleanup failed: ${cleanupResponse.status()} ${text}`);
}
}
async function installPromptAutoAccept(page, code = "000000") {
page.on("dialog", async (dialog) => {
if (dialog.type() === "prompt") {
await dialog.accept(code);
return;
}
await dialog.accept();
});
}
async function installOtpBypassRoutes(page) {
await page.route("**/api/public/otp/send", async (route) => {
let purpose = "CREATE_REQUEST";
try {
const body = JSON.parse(route.request().postData() || "{}");
purpose = String(body.purpose || purpose);
} catch (_) {}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "sent",
purpose,
ttl_seconds: 600,
sms_response: { provider: "e2e", status: "accepted", message: "ok" },
}),
});
});
await page.route("**/api/public/otp/verify", async (route) => {
let purpose = "CREATE_REQUEST";
try {
const body = JSON.parse(route.request().postData() || "{}");
purpose = String(body.purpose || purpose);
} catch (_) {}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "verified", purpose }),
});
});
}
async function preparePublicSession(context, page, appUrl, phone) {
await context.addCookies([
{
name: PUBLIC_COOKIE_NAME,
value: createPublicCookieToken(phone),
url: `${appUrl}/`,
httpOnly: true,
sameSite: "Lax",
},
]);
await installPromptAutoAccept(page);
await installOtpBypassRoutes(page);
}
async function createRequestViaLanding(page, options = {}) {
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = options.phone || randomPhone();
const name = options.name || `Клиент E2E ${Date.now()}`;
const description = options.description || "Проверка создания заявки через UI";
const topicsResponse = await page.request.get(`${baseUrl}/api/public/requests/topics`, {
headers: {
Origin: baseUrl,
Referer: `${baseUrl}/`,
},
});
if (!topicsResponse.ok()) {
throw new Error(`Не удалось загрузить темы: ${topicsResponse.status()} ${await topicsResponse.text().catch(() => "")}`);
}
const topics = await topicsResponse.json();
const topicCode = String(topics?.[0]?.code || "").trim();
if (!topicCode) {
throw new Error("Не найдена доступная тема для E2E-создания заявки");
}
const createResponse = await page.request.post(`${baseUrl}/api/public/requests`, {
headers: {
Origin: baseUrl,
Referer: `${baseUrl}/`,
},
data: {
client_name: name,
client_phone: phone,
client_email: options.email || "",
topic_code: topicCode,
description,
pdn_consent: true,
extra_fields: {},
},
failOnStatusCode: false,
});
const createPayload = await createResponse.json().catch(() => null);
if (!createResponse.ok()) {
throw new Error(`Не удалось создать заявку: ${createResponse.status()} ${createPayload?.detail || JSON.stringify(createPayload || {})}`);
}
const trackNumber = String(createPayload?.track_number || "").trim().toUpperCase();
if (!trackNumber) {
throw new Error("Track number not returned by public create request");
}
await page.context().addCookies([
{
name: PUBLIC_COOKIE_NAME,
value: createPublicViewCookieToken(trackNumber),
url: `${baseUrl}/`,
httpOnly: true,
sameSite: "Lax",
},
]);
return { trackNumber, phone, name };
}
async function openPublicCabinet(page, trackNumber) {
await page.goto(`/client.html?track=${encodeURIComponent(trackNumber)}`);
await expect(page.locator("#cabinet-summary")).toBeVisible();
await expect(page.locator("#cabinet-request-status")).not.toHaveText("-");
}
async function sendCabinetMessage(page, text) {
await page.locator("#cabinet-chat-body").fill(text);
await page.locator("#cabinet-chat-send").click();
await expect(page.locator("#client-page-status")).toContainText("Сообщение отправлено.");
await expect(page.locator("#cabinet-messages")).toContainText(text);
}
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
let lastError = null;
const mimeType = detectMimeForFixture(fileName);
const buffer = mimeType === "application/pdf" ? buildTinyPdfBuffer(bodyText) : Buffer.from(bodyText, "utf-8");
for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#cabinet-file-input").setInputFiles({
name: fileName,
mimeType,
buffer,
});
await page.locator("#cabinet-chat-send").click();
try {
await expect(page.locator("#client-page-status")).toContainText("Файл загружен.", { timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.waitForTimeout(500);
}
}
if (lastError) throw lastError;
const filesTab = page.getByRole("tab", { name: /Файлы/ });
if (await filesTab.count()) {
await filesTab.click();
}
await expect(page.locator("#cabinet-files")).toContainText(fileName);
}
async function loginAdminPanel(page, creds) {
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
let loginVisible = false;
const startedAt = Date.now();
while (Date.now() - startedAt < 15_000) {
loginVisible = await page.locator("#login-email").isVisible().catch(() => false);
if (loginVisible) break;
const badge = (await page.locator(".badge").first().textContent().catch(() => "")) || "";
if (badge && !badge.includes("роль: -")) break;
await page.waitForTimeout(200);
}
if (loginVisible) {
await page.locator("#login-email").fill(creds.email);
await page.locator("#login-password").fill(creds.password);
await page.getByRole("button", { name: "Войти" }).click();
}
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
}
async function openRequestsSection(page) {
await page.locator("aside .menu button[data-section='requests']").click();
await expect(page.locator("#section-requests h2")).toHaveText("Заявки");
}
function dropdownLocator(page, target) {
return typeof target === "string" ? page.locator(target) : target;
}
async function openDropdown(page, target) {
const trigger = dropdownLocator(page, target);
await expect(trigger).toBeVisible();
await trigger.click();
const root = trigger.locator("xpath=ancestor-or-self::*[contains(concat(' ', normalize-space(@class), ' '), ' dropdown-field ')]").first();
await expect(root.locator(".dropdown-field-menu")).toBeVisible();
return root;
}
async function selectDropdownOption(page, target, optionText) {
const root = await openDropdown(page, target);
const option = root.locator(".dropdown-field-option").filter({ hasText: optionText }).first();
await expect(option).toBeVisible();
await option.click();
}
async function selectFirstDropdownOption(page, target) {
const root = await openDropdown(page, target);
const option = root.locator(".dropdown-field-option").first();
await expect(option).toBeVisible();
const label = ((await option.textContent()) || "").trim();
await option.click();
return label;
}
function rowByTrack(page, sectionSelector, trackNumber) {
return page.locator(`${sectionSelector} table tbody tr`).filter({ hasText: trackNumber });
}
async function openDictionaryTree(page) {
const treeButton = page.locator("aside .menu button", { hasText: "Справочники" }).first();
await treeButton.click();
const afterFirstClick = await treeButton.innerText();
if (afterFirstClick.includes("▸")) {
await treeButton.click();
}
await expect(page.locator("#section-config h2")).toHaveText("Справочники");
await expect(treeButton).toContainText("▾");
await expect.poll(async () => page.locator("aside .menu .menu-tree button").count(), { timeout: 30_000 }).toBeGreaterThan(0);
}
async function selectDictionaryNode(page, label) {
await page.locator("aside .menu .menu-tree").getByRole("button", { name: label, exact: true }).click();
await expect(page.locator("#section-config .section-head .breadcrumbs")).toContainText(label);
}
module.exports = {
randomPhone,
createCleanupTracker,
trackCleanupPhone,
trackCleanupTrack,
trackCleanupEmail,
cleanupTrackedTestData,
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
loginAdminPanel,
openRequestsSection,
openDropdown,
rowByTrack,
selectDropdownOption,
selectFirstDropdownOption,
openDictionaryTree,
selectDictionaryNode,
buildTinyPdfBuffer,
};