Files

191 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
});