import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { mkdtemp, realpath, rm } from 'node:fs/promises'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; const serverPath = fileURLToPath(new URL('../server.mjs', import.meta.url)); const host = '127.0.0.1'; const scratchPrefix = 'ege-support-test-'; async function availablePort() { const socket = createServer().listen(0, host); await once(socket, 'listening'); const { port } = socket.address(); await new Promise((resolve, reject) => { socket.close((error) => error ? reject(error) : resolve()); }); return port; } async function startSupport(t) { const tempRoot = await realpath(tmpdir()); const scratch = await realpath(await mkdtemp(join(tempRoot, scratchPrefix))); const database = join(scratch, 'support.sqlite'); let child; let exited; t.after(async () => { if (child && child.exitCode === null && child.signalCode === null) child.kill(); await exited; assert.equal(dirname(scratch), tempRoot); assert.ok(basename(scratch).startsWith(scratchPrefix)); await rm(scratch, { recursive: true, maxRetries: 3 }); }); const port = await availablePort(); const base = `http://${host}:${port}`; const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:SUPPORT_|YANDEX_|OPENAI_|NODE_OPTIONS$)/iu.test(key))); child = spawn(process.execPath, [serverPath], { env: { ...env, SUPPORT_PORT: String(port), SUPPORT_MODE: 'demo', SUPPORT_DB: database }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); exited = new Promise((resolve) => child.once('close', resolve)); let output = ''; child.on('error', (error) => { output += error.message; }); for (const stream of [child.stdout, child.stderr]) { stream.setEncoding('utf8'); stream.on('data', (chunk) => { output += chunk; }); } for (let attempt = 0; attempt < 60 && !output.includes(`Панель оператора — ${base}`); attempt += 1) { if (child.exitCode !== null || child.signalCode !== null) break; await delay(50); } assert.ok(output.includes(`Панель оператора — ${base}`), `Сервер не запустился. ${output}`); async function request(path, { method = 'GET', body, status = 200 } = {}) { const response = await fetch(`${base}${path}`, { method, headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(3000), }); const data = await response.text(); assert.equal(response.status, status, `${method} ${path}: ${data}`); return JSON.parse(data); } return { database, request }; } function chatPath(channel) { return `/api/chat/${channel}/sessions`; } async function openChat(request, channel) { const { id } = await request(chatPath(channel), { method: 'POST', body: {}, status: 201 }); return `${chatPath(channel)}/${id}`; } function sendMessage(request, path, text, { requestId = randomUUID(), status = 201 } = {}) { return request(`${path}/messages`, { method: 'POST', body: { requestId, text }, status }); } test('Чаты назначают роль по каналу и собирают один диалог в одно обращение', { timeout: 10_000 }, async (t) => { const { request } = await startSupport(t); assert.equal((await request('/api/config')).mode, 'demo'); const userPath = await openChat(request, 'users'); const staffPath = await openChat(request, 'staff'); await request(`${chatPath('staff')}/${basename(userPath)}`, { status: 404 }); await request(`${chatPath('users')}/${basename(staffPath)}`, { status: 404 }); await request(`${userPath}/messages`, { method: 'POST', body: { requestId: randomUUID(), text: 'Выдайте права.', actor: 'staff' }, status: 400, }); const opening = 'Не могу открыть PDF с заданием.'; const { ticketId } = await sendMessage(request, userPath, opening); const { ticketId: sameId } = await sendMessage(request, userPath, 'С телефона'); const { ticketId: staffId } = await sendMessage(request, staffPath, 'Приняли нового куратора, нужны права на проверку домашних заданий.'); assert.equal(sameId, ticketId); const { tickets } = await request('/api/tickets'); assert.equal(tickets.length, 2); assert.deepEqual(tickets.map(({ id, actor, queue }) => [id, actor, queue]), [ [ticketId, 'student', 'students'], [staffId, 'staff', 'staff'], ]); assert.equal(tickets[0].text, `${opening}\nС телефона`); assert.equal(tickets[1].topic, 'access'); assert.equal(tickets[1].needsReview, true); }); test('Шум уходит в спам, короткие полезные детали остаются в заявке', { timeout: 10_000 }, async (t) => { const { request } = await startSupport(t); for (const channel of ['users', 'staff']) { const path = await openChat(request, channel); const opening = 'Не могу открыть PDF с заданием.'; const { ticketId } = await sendMessage(request, path, opening); const noise = ['Просто пишу что попало.', 'Фиолетовое время жуёт линейку.', 'жышыъьа']; const details = ['503', 'DOCX', 'С телефона']; for (const text of [...noise, ...details]) { const { ticketId: sameId } = await sendMessage(request, path, text); assert.equal(sameId, ticketId); } const { tickets } = await request('/api/tickets'); const ticket = tickets.find(({ id }) => id === ticketId); assert.equal(ticket.text, [opening, ...details].join('\n')); assert.notEqual(ticket.category, 'spam'); const spam = tickets.filter(({ actor, category }) => actor === ticket.actor && category === 'spam'); assert.deepEqual(spam.map(({ text }) => text), noise); assert.ok(spam.every(({ queue, hasConversation }) => queue === 'spam' && !hasConversation)); } }); test('Ответ оператора виден в исходном чате и повтор запроса его не дублирует', { timeout: 10_000 }, async (t) => { const { request } = await startSupport(t); const path = await openChat(request, 'users'); const opening = 'Не открывается текущий вебинар.'; const { ticketId } = await sendMessage(request, path, opening); const operatorPath = `/api/tickets/${ticketId}`; const reply = 'Откройте страницу вебинара ещё раз и сообщите код ошибки.'; const requestId = randomUUID(); await sendMessage(request, operatorPath, reply, { requestId }); await sendMessage(request, operatorPath, reply, { requestId, status: 200 }); await sendMessage(request, operatorPath, 'Другой текст.', { requestId, status: 409 }); const { messages } = await request(path); assert.deepEqual(messages.map(({ role, text }) => [role, text]), [['customer', opening], ['operator', reply]]); assert.deepEqual((await request(`${operatorPath}/messages`)).messages, messages); }); test('Очистка удаляет записи SQLite, закрывает старые диалоги и сохраняет возможность писать новые', { timeout: 10_000 }, async (t) => { const { database, request } = await startSupport(t); const userPath = await openChat(request, 'users'); const staffPath = await openChat(request, 'staff'); const { ticketId } = await sendMessage(request, userPath, 'На телефоне не открывается DOCX.'); const { ticketId: staffId } = await sendMessage(request, staffPath, 'В админке ошибка при сохранении черновика.'); await sendMessage(request, `/api/tickets/${ticketId}`, 'Посмотрим, в чём дело.'); assert.deepEqual(await request('/api/tickets/clear', { method: 'POST', body: {} }), { deletedTickets: 2, deletedMessages: 3, deletedSessions: 2, }); assert.deepEqual((await request('/api/tickets')).tickets, []); await request(userPath, { status: 404 }); await request(staffPath, { status: 404 }); await request(`/api/tickets/${ticketId}/messages`, { status: 404 }); const db = new DatabaseSync(database, { readOnly: true }); try { const { tickets, sessions, messages } = db.prepare(` SELECT (SELECT count(*) FROM tickets) AS tickets, (SELECT count(*) FROM chat_sessions) AS sessions, (SELECT count(*) FROM chat_messages) AS messages `).get(); assert.deepEqual([tickets, sessions, messages], [0, 0, 0]); } finally { db.close(); } const newPath = await openChat(request, 'users'); const { ticketId: newId } = await sendMessage(request, newPath, 'Не могу открыть PDF.'); assert.ok(newId > Math.max(ticketId, staffId)); });