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, };