191 lines
9.1 KiB
JavaScript
191 lines
9.1 KiB
JavaScript
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));
|
||
});
|