feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»

Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 23:17:04 +03:00
parent db1db68488
commit 43df41287f
6 changed files with 168 additions and 2 deletions
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/**
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, db, cleanup } = require('./setup');
app.use('/api/client-errors', require('../src/routes/clientErrors'));
after(() => cleanup());
describe('/api/client-errors', () => {
let student;
before(async () => { student = await getToken('student'); });
it('требует авторизацию (401)', async () => {
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
assert.equal(res.status, 401);
});
it('пустое сообщение → 400', async () => {
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
assert.equal(res.status, 400);
});
it('пишет ошибку в error_log с level=client', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'error', message: 'TypeError: x is null',
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
url: '/lab?sim=demo#x',
}, student.token);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
).get(student.userId);
assert.ok(row, 'строка должна появиться');
assert.equal(row.message, 'TypeError: x is null');
assert.equal(row.route, '/lab?sim=demo#x');
assert.equal(row.method, 'error');
assert.match(row.stack, /app\.js:10:5/);
});
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
).get();
assert.equal(row.method, 'rejection');
assert.match(row.stack, /x\.js:3:1/);
});
it('длинные поля обрезаются (не падает)', async () => {
const res = await inject('POST', '/api/client-errors', {
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
assert.ok(row.message.length <= 1000);
assert.ok(row.stack.length <= 4000);
assert.ok(row.route.length <= 400);
});
});