Add EGE support prototype v0.1.0
This commit is contained in:
1 parent
9944a41cca
commit
24cae23262
19 files changed
+2581
-2
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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));
|
||||
});
|
||||
Reference in new issue
Block a user