mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +03:00
439 lines
14 KiB
JavaScript
439 lines
14 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 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-создания заявки");
|
||
}
|
||
|
||
// Ensure the CREATE_REQUEST cookie is active before posting.
|
||
// A prior createRequestViaLanding call may have replaced it with VIEW_REQUEST.
|
||
await page.context().addCookies([
|
||
{
|
||
name: PUBLIC_COOKIE_NAME,
|
||
value: createPublicCookieToken(phone),
|
||
url: `${baseUrl}/`,
|
||
httpOnly: true,
|
||
sameSite: "Lax",
|
||
},
|
||
]);
|
||
|
||
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,
|
||
};
|