Law/e2e/tests/helpers.js
2026-02-27 20:43:57 +03:00

389 lines
13 KiB
JavaScript

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 phone = options.phone || randomPhone();
const name = options.name || `Клиент E2E ${Date.now()}`;
const description = options.description || "Проверка создания заявки через UI";
await page.goto("/");
await expect(page.getByRole("heading", { name: "Решаем сложные юридические задачи в интересах вашего бизнеса." })).toBeVisible();
await page.getByRole("button", { name: "Оставить заявку" }).first().click();
await expect(page.getByRole("heading", { name: "Создание заявки" })).toBeVisible();
await page.locator("#name").fill(name);
await page.locator("#phone").fill(phone);
const topicSelect = page.locator("#topic");
await topicSelect.waitFor();
await topicSelect.selectOption({ index: 1 });
await page.locator("#description").fill(description);
await page.getByRole("button", { name: "Отправить заявку" }).click();
const otpModal = page.locator("#otp-modal");
const otpCodeInput = page.locator("#otp-modal-code");
const otpSubmit = page.locator("#otp-modal-submit");
if (await otpModal.isVisible().catch(() => false)) {
await otpCodeInput.fill("000000");
await otpSubmit.click();
} else {
await otpModal.waitFor({ state: "visible", timeout: 5000 }).catch(() => null);
if (await otpModal.isVisible().catch(() => false)) {
await otpCodeInput.fill("000000");
await otpSubmit.click();
}
}
await expect(page.locator("#form-status")).toContainText("Заявка принята. Номер:");
const statusText = await page.locator("#form-status").innerText();
const match = statusText.match(/TRK-[A-Z0-9-]+/);
if (!match) throw new Error("Track number not found in form status");
return { trackNumber: match[0], phone, name };
}
async function openPublicCabinet(page, trackNumber) {
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
await page.context().addCookies([
{
name: PUBLIC_COOKIE_NAME,
value: createPublicViewCookieToken(String(trackNumber || "").trim().toUpperCase()),
url: `${baseUrl}/`,
httpOnly: true,
sameSite: "Lax",
},
]);
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 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 .config-panel h3")).toContainText(label);
}
module.exports = {
randomPhone,
createCleanupTracker,
trackCleanupPhone,
trackCleanupTrack,
trackCleanupEmail,
cleanupTrackedTestData,
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
sendCabinetMessage,
uploadCabinetFile,
loginAdminPanel,
openRequestsSection,
rowByTrack,
openDictionaryTree,
selectDictionaryNode,
buildTinyPdfBuffer,
};