feat(tests): update test scripts for backend and frontend, add CI testing workflow
Publish Fast Tabler Docker image / Test before build (push) Failing after 7m0s
Publish Fast Tabler Docker image / Build and push image (push) Has been skipped
Test and Lint / Backend tests (push) Successful in 9m38s
Test and Lint / Frontend lint, test, build (push) Failing after 5m38s
Publish Fast Tabler Docker image / Test before build (push) Failing after 7m0s
Publish Fast Tabler Docker image / Build and push image (push) Has been skipped
Test and Lint / Backend tests (push) Successful in 9m38s
Test and Lint / Frontend lint, test, build (push) Failing after 5m38s
This commit is contained in:
@@ -7,7 +7,25 @@ on:
|
|||||||
- v3
|
- v3
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test before build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Backend tests
|
||||||
|
run: npm ci && npm test
|
||||||
|
working-directory: backend
|
||||||
|
- name: Frontend lint, typecheck, test, build
|
||||||
|
run: npm ci && npm run lint && npm run typecheck && npm test && npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
build-and-push-fast:
|
build-and-push-fast:
|
||||||
|
name: Build and push image
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Запуск линта, тестов и проверки сборки перед деплоем
|
||||||
|
name: Test and Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- tabler
|
||||||
|
- v3
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: npm test
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend lint, test, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node_modules\\.bin\\nodemon server.js",
|
"dev": "node_modules\\.bin\\nodemon server.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Unit-тесты для backend/utils/helpers.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const {
|
||||||
|
toIso,
|
||||||
|
splitWhitespace,
|
||||||
|
sha256OfString,
|
||||||
|
mapAjvErrors,
|
||||||
|
resourceToKey,
|
||||||
|
buildNestedGatewayBlocks,
|
||||||
|
} = require('../utils/helpers');
|
||||||
|
|
||||||
|
describe('helpers', () => {
|
||||||
|
describe('toIso', () => {
|
||||||
|
it('конвертирует дату в ISO строку', () => {
|
||||||
|
const d = new Date('2025-01-15T12:00:00.000Z');
|
||||||
|
assert.strictEqual(toIso(d), '2025-01-15T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
it('возвращает null для невалидной даты', () => {
|
||||||
|
assert.strictEqual(toIso('not a date'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitWhitespace', () => {
|
||||||
|
it('разбивает по пробелам и триммит', () => {
|
||||||
|
assert.deepStrictEqual(splitWhitespace(' a b c '), ['a', 'b', 'c']);
|
||||||
|
assert.deepStrictEqual(splitWhitespace('one'), ['one']);
|
||||||
|
});
|
||||||
|
it('обрабатывает пустую строку и null (trim даёт пустую строку, split — один элемент)', () => {
|
||||||
|
assert.deepStrictEqual(splitWhitespace(''), ['']);
|
||||||
|
assert.deepStrictEqual(splitWhitespace(null), ['']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sha256OfString', () => {
|
||||||
|
it('возвращает hex-хэш фиксированной длины', () => {
|
||||||
|
const h = sha256OfString('hello');
|
||||||
|
assert.strictEqual(typeof h, 'string');
|
||||||
|
assert.strictEqual(h.length, 64);
|
||||||
|
assert.ok(/^[a-f0-9]+$/.test(h));
|
||||||
|
});
|
||||||
|
it('детерминирован', () => {
|
||||||
|
assert.strictEqual(sha256OfString('test'), sha256OfString('test'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapAjvErrors', () => {
|
||||||
|
it('маппит массив ошибок AJV', () => {
|
||||||
|
const ajvErrors = [
|
||||||
|
{ message: 'err1', instancePath: '/a', keyword: 'type', params: {} },
|
||||||
|
];
|
||||||
|
const mapped = mapAjvErrors(ajvErrors);
|
||||||
|
assert.strictEqual(mapped.length, 1);
|
||||||
|
assert.strictEqual(mapped[0].message, 'err1');
|
||||||
|
assert.strictEqual(mapped[0].instancePath, '/a');
|
||||||
|
});
|
||||||
|
it('возвращает [] для не-массива', () => {
|
||||||
|
assert.deepStrictEqual(mapAjvErrors(null), []);
|
||||||
|
assert.deepStrictEqual(mapAjvErrors(undefined), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resourceToKey', () => {
|
||||||
|
it('маппит известные ресурсы на S3 ключи', () => {
|
||||||
|
assert.strictEqual(resourceToKey('domains-new'), 'bgp_data/domains_community.txt');
|
||||||
|
assert.strictEqual(resourceToKey('ip-ranges'), 'bgp_data/ips.txt');
|
||||||
|
assert.strictEqual(resourceToKey('asns'), 'bgp_data/asns.txt');
|
||||||
|
});
|
||||||
|
it('возвращает null для неизвестного', () => {
|
||||||
|
assert.strictEqual(resourceToKey('unknown'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildNestedGatewayBlocks', () => {
|
||||||
|
it('возвращает пустую строку для пустого объекта', () => {
|
||||||
|
assert.strictEqual(buildNestedGatewayBlocks({}), '');
|
||||||
|
});
|
||||||
|
it('строит вложенные if/else с set gw и accept/reject', () => {
|
||||||
|
const blocks = buildNestedGatewayBlocks({
|
||||||
|
gw1: ['65000:100'],
|
||||||
|
gw2: ['65001:200'],
|
||||||
|
});
|
||||||
|
assert.ok(blocks.includes('set gw gw1'));
|
||||||
|
assert.ok(blocks.includes('set gw gw2'));
|
||||||
|
assert.ok(blocks.includes('reject'));
|
||||||
|
assert.ok(blocks.includes('bgp-communities includes 65000:100'));
|
||||||
|
assert.ok(blocks.includes('bgp-communities includes 65001:200'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Unit-тесты для backend/lib/mikrotik-validator.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { validateMikrotikConfig } = require('../lib/mikrotik-validator');
|
||||||
|
|
||||||
|
describe('mikrotik-validator', () => {
|
||||||
|
describe('validateMikrotikConfig', () => {
|
||||||
|
it('возвращает ошибку для пустой или не строки', () => {
|
||||||
|
let r = validateMikrotikConfig('');
|
||||||
|
assert.strictEqual(r.valid, false);
|
||||||
|
assert.ok(r.errors.length > 0);
|
||||||
|
|
||||||
|
r = validateMikrotikConfig(null);
|
||||||
|
assert.strictEqual(r.valid, false);
|
||||||
|
|
||||||
|
r = validateMikrotikConfig(123);
|
||||||
|
assert.strictEqual(r.valid, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('принимает валидный минимальный блок с закрытой скобкой', () => {
|
||||||
|
const config = `
|
||||||
|
/routing filter bgp-in-tmp {
|
||||||
|
if (bgp-communities includes 65000:100)
|
||||||
|
{ set gw gw1; accept; }
|
||||||
|
else { reject; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const r = validateMikrotikConfig(config);
|
||||||
|
assert.strictEqual(r.valid, true, 'errors: ' + JSON.stringify(r.errors));
|
||||||
|
assert.strictEqual(r.errors.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка при несбалансированных фигурных скобках', () => {
|
||||||
|
const config = `
|
||||||
|
/routing filter bgp-in-tmp {
|
||||||
|
if (bgp-communities includes 65000:100)
|
||||||
|
{ set gw gw1; accept; }
|
||||||
|
`;
|
||||||
|
const r = validateMikrotikConfig(config);
|
||||||
|
assert.strictEqual(r.valid, false);
|
||||||
|
const braceError = r.errors.find(e => e.message.includes('скобк') || e.message.includes('не закрыт'));
|
||||||
|
assert.ok(braceError, 'ожидалась ошибка про скобки или незакрытый блок');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('игнорирует комментарии и пустые строки', () => {
|
||||||
|
const config = `
|
||||||
|
// comment
|
||||||
|
# another comment
|
||||||
|
|
||||||
|
/routing filter bgp-in-tmp {
|
||||||
|
if (bgp-communities includes 65000:100) { set gw gw1; accept; }
|
||||||
|
else { reject; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const r = validateMikrotikConfig(config);
|
||||||
|
assert.strictEqual(r.valid, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Unit-тесты для backend/lib/validators.js
|
||||||
|
*/
|
||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const {
|
||||||
|
isValidIPv4,
|
||||||
|
isValidIPv6,
|
||||||
|
isValidCIDRv4,
|
||||||
|
isValidCIDRv6,
|
||||||
|
isValidDomain,
|
||||||
|
isValidWildcardDomain,
|
||||||
|
isValidASN,
|
||||||
|
isValidCommunity,
|
||||||
|
isValidGateway,
|
||||||
|
sanitizeString,
|
||||||
|
isSafeSQLString,
|
||||||
|
isSafeXSSString,
|
||||||
|
validateData,
|
||||||
|
} = require('../lib/validators');
|
||||||
|
|
||||||
|
describe('validators', () => {
|
||||||
|
describe('isValidIPv4', () => {
|
||||||
|
it('принимает валидные IPv4', () => {
|
||||||
|
assert.strictEqual(isValidIPv4('192.168.1.1'), true);
|
||||||
|
assert.strictEqual(isValidIPv4('0.0.0.0'), true);
|
||||||
|
assert.strictEqual(isValidIPv4('255.255.255.255'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные IPv4', () => {
|
||||||
|
assert.strictEqual(isValidIPv4('256.1.1.1'), false);
|
||||||
|
assert.strictEqual(isValidIPv4('192.168.1'), false);
|
||||||
|
assert.strictEqual(isValidIPv4(''), false);
|
||||||
|
assert.strictEqual(isValidIPv4(null), false);
|
||||||
|
assert.strictEqual(isValidIPv4(123), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIPv6', () => {
|
||||||
|
it('принимает валидные IPv6', () => {
|
||||||
|
assert.strictEqual(isValidIPv6('2001:db8::1'), true);
|
||||||
|
assert.strictEqual(isValidIPv6('::1'), true);
|
||||||
|
assert.strictEqual(isValidIPv6('fe80::1'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
assert.strictEqual(isValidIPv6(''), false);
|
||||||
|
assert.strictEqual(isValidIPv6('192.168.1.1'), false);
|
||||||
|
assert.strictEqual(isValidIPv6('gggg::1'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidCIDRv4', () => {
|
||||||
|
it('принимает валидные CIDR', () => {
|
||||||
|
assert.strictEqual(isValidCIDRv4('192.168.0.0/24'), true);
|
||||||
|
assert.strictEqual(isValidCIDRv4('10.0.0.0/8'), true);
|
||||||
|
assert.strictEqual(isValidCIDRv4('0.0.0.0/0'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
assert.strictEqual(isValidCIDRv4('192.168.0.0/33'), false);
|
||||||
|
assert.strictEqual(isValidCIDRv4('192.168.0/24'), false);
|
||||||
|
assert.strictEqual(isValidCIDRv4('192.168.0.0'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidCIDRv6', () => {
|
||||||
|
it('принимает валидные CIDR IPv6', () => {
|
||||||
|
assert.strictEqual(isValidCIDRv6('2001:db8::/32'), true);
|
||||||
|
assert.strictEqual(isValidCIDRv6('::/0'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
assert.strictEqual(isValidCIDRv6('2001:db8::/129'), false);
|
||||||
|
assert.strictEqual(isValidCIDRv6('192.168.0.0/24'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidDomain', () => {
|
||||||
|
it('принимает валидные домены', () => {
|
||||||
|
assert.strictEqual(isValidDomain('example.com'), true);
|
||||||
|
assert.strictEqual(isValidDomain('sub.example.com'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет IP и пустые', () => {
|
||||||
|
assert.strictEqual(isValidDomain('192.168.1.1'), false);
|
||||||
|
assert.strictEqual(isValidDomain(''), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidWildcardDomain', () => {
|
||||||
|
it('принимает *.example.com', () => {
|
||||||
|
assert.strictEqual(isValidWildcardDomain('*.example.com'), true);
|
||||||
|
});
|
||||||
|
it('принимает обычный домен', () => {
|
||||||
|
assert.strictEqual(isValidWildcardDomain('example.com'), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidASN', () => {
|
||||||
|
it('принимает ASN в разных форматах', () => {
|
||||||
|
assert.strictEqual(isValidASN('AS12345'), true);
|
||||||
|
assert.strictEqual(isValidASN('12345'), true);
|
||||||
|
assert.strictEqual(isValidASN(65535), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
assert.strictEqual(isValidASN('ASN123'), false);
|
||||||
|
assert.strictEqual(isValidASN(''), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidCommunity', () => {
|
||||||
|
it('принимает N:N и N', () => {
|
||||||
|
assert.strictEqual(isValidCommunity('65000:100'), true);
|
||||||
|
assert.strictEqual(isValidCommunity('65000'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
assert.strictEqual(isValidCommunity('abc:100'), false);
|
||||||
|
assert.strictEqual(isValidCommunity(''), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidGateway', () => {
|
||||||
|
it('принимает буквы, цифры, дефис, подчёркивание', () => {
|
||||||
|
assert.strictEqual(isValidGateway('gw1'), true);
|
||||||
|
assert.strictEqual(isValidGateway('my-gateway_1'), true);
|
||||||
|
});
|
||||||
|
it('отклоняет пустые и слишком длинные', () => {
|
||||||
|
assert.strictEqual(isValidGateway(''), false);
|
||||||
|
assert.strictEqual(isValidGateway('a'.repeat(65)), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeString', () => {
|
||||||
|
it('экранирует HTML и удаляет управляющие символы', () => {
|
||||||
|
assert.ok(sanitizeString('<script>').includes('<'));
|
||||||
|
assert.ok(sanitizeString(' ok ').trim() === 'ok');
|
||||||
|
});
|
||||||
|
it('возвращает пустую строку для не-строки', () => {
|
||||||
|
assert.strictEqual(sanitizeString(null), '');
|
||||||
|
assert.strictEqual(sanitizeString(123), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSafeSQLString', () => {
|
||||||
|
it('считает опасными SQL-паттерны', () => {
|
||||||
|
assert.strictEqual(isSafeSQLString('SELECT * FROM users'), false);
|
||||||
|
assert.strictEqual(isSafeSQLString("1 OR 1=1"), false);
|
||||||
|
});
|
||||||
|
it('считает безопасными обычный текст', () => {
|
||||||
|
assert.strictEqual(isSafeSQLString('hello'), true);
|
||||||
|
assert.strictEqual(isSafeSQLString(''), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSafeXSSString', () => {
|
||||||
|
it('считает опасными XSS-паттерны', () => {
|
||||||
|
assert.strictEqual(isSafeXSSString('<script>alert(1)</script>'), false);
|
||||||
|
assert.strictEqual(isSafeXSSString('javascript:alert(1)'), false);
|
||||||
|
});
|
||||||
|
it('считает безопасными обычный текст', () => {
|
||||||
|
assert.strictEqual(isSafeXSSString('hello'), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateData', () => {
|
||||||
|
it('валидирует по схеме и возвращает errors', () => {
|
||||||
|
const result = validateData(
|
||||||
|
{ ip: '256.1.1.1' },
|
||||||
|
{ ip: { type: 'ipv4', required: true } }
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.valid, false);
|
||||||
|
assert.ok(result.errors.length > 0);
|
||||||
|
});
|
||||||
|
it('valid: true когда всё ок', () => {
|
||||||
|
const result = validateData(
|
||||||
|
{ ip: '192.168.1.1' },
|
||||||
|
{ ip: { type: 'ipv4', required: true } }
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.valid, true);
|
||||||
|
assert.strictEqual(result.errors.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1533
-148
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:check": "tsc -b && vite build",
|
"build:check": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -36,7 +38,8 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-musl": "4.40.0"
|
"@rollup/rollup-linux-x64-musl": "4.40.0"
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Unit-тесты для frontend/src/lib/apiErrorHandler.js
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
ErrorType,
|
||||||
|
getErrorType,
|
||||||
|
isRetriableError,
|
||||||
|
getRetryDelay,
|
||||||
|
getMaxRetries,
|
||||||
|
formatErrorMessage,
|
||||||
|
getErrorDetails,
|
||||||
|
isCriticalError,
|
||||||
|
getErrorAction,
|
||||||
|
} from './apiErrorHandler';
|
||||||
|
|
||||||
|
function makeError(status, data = {}, code = null) {
|
||||||
|
const err = new Error(data.message || 'Error');
|
||||||
|
err.response = { status, data };
|
||||||
|
if (code) err.code = code;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apiErrorHandler', () => {
|
||||||
|
describe('getErrorType', () => {
|
||||||
|
it('возвращает UNKNOWN для null/undefined', () => {
|
||||||
|
expect(getErrorType(null)).toBe(ErrorType.UNKNOWN);
|
||||||
|
});
|
||||||
|
it('возвращает TIMEOUT для ECONNABORTED', () => {
|
||||||
|
expect(getErrorType({ code: 'ECONNABORTED' })).toBe(ErrorType.TIMEOUT);
|
||||||
|
expect(getErrorType({ message: 'timeout of 5000ms exceeded' })).toBe(ErrorType.TIMEOUT);
|
||||||
|
});
|
||||||
|
it('возвращает NETWORK при отсутствии response', () => {
|
||||||
|
expect(getErrorType(new Error('Network Error'))).toBe(ErrorType.NETWORK);
|
||||||
|
});
|
||||||
|
it('маппит статусы в типы', () => {
|
||||||
|
expect(getErrorType(makeError(400))).toBe(ErrorType.VALIDATION);
|
||||||
|
expect(getErrorType(makeError(401))).toBe(ErrorType.AUTH);
|
||||||
|
expect(getErrorType(makeError(403))).toBe(ErrorType.PERMISSION);
|
||||||
|
expect(getErrorType(makeError(404))).toBe(ErrorType.NOT_FOUND);
|
||||||
|
expect(getErrorType(makeError(409))).toBe(ErrorType.CONFLICT);
|
||||||
|
expect(getErrorType(makeError(429))).toBe(ErrorType.RATE_LIMIT);
|
||||||
|
expect(getErrorType(makeError(500))).toBe(ErrorType.SERVER);
|
||||||
|
expect(getErrorType(makeError(418))).toBe(ErrorType.CLIENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRetriableError', () => {
|
||||||
|
it('для GET разрешает retry при NETWORK, TIMEOUT, SERVER', () => {
|
||||||
|
expect(isRetriableError({ response: undefined }, 'GET')).toBe(true);
|
||||||
|
expect(isRetriableError(makeError(500), 'GET')).toBe(true);
|
||||||
|
expect(isRetriableError(makeError(404), 'GET')).toBe(false);
|
||||||
|
});
|
||||||
|
it('для POST разрешает retry только при NETWORK, TIMEOUT', () => {
|
||||||
|
expect(isRetriableError(makeError(500), 'POST')).toBe(false);
|
||||||
|
expect(isRetriableError({ response: undefined }, 'POST')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRetryDelay', () => {
|
||||||
|
it('возвращает число в разумных границах', () => {
|
||||||
|
const d = getRetryDelay(0, 300);
|
||||||
|
expect(typeof d).toBe('number');
|
||||||
|
expect(d).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(d).toBeLessThanOrEqual(10000);
|
||||||
|
});
|
||||||
|
it('увеличивается с attemptNumber', () => {
|
||||||
|
const d0 = getRetryDelay(0, 300);
|
||||||
|
const d1 = getRetryDelay(1, 300);
|
||||||
|
const d2 = getRetryDelay(2, 300);
|
||||||
|
expect(d1).toBeGreaterThanOrEqual(d0 * 1.5);
|
||||||
|
expect(d2).toBeGreaterThanOrEqual(d1 * 1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMaxRetries', () => {
|
||||||
|
it('для GET возвращает 3 при NETWORK', () => {
|
||||||
|
expect(getMaxRetries(ErrorType.NETWORK, 'GET')).toBe(3);
|
||||||
|
});
|
||||||
|
it('для POST возвращает 0 для SERVER', () => {
|
||||||
|
expect(getMaxRetries(ErrorType.SERVER, 'POST')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatErrorMessage', () => {
|
||||||
|
it('использует data.message если есть', () => {
|
||||||
|
const err = makeError(400, { message: 'Custom message' });
|
||||||
|
expect(formatErrorMessage(err)).toBe('Custom message');
|
||||||
|
});
|
||||||
|
it('возвращает читаемые сообщения по типу', () => {
|
||||||
|
expect(formatErrorMessage(makeError(404))).toContain('не найден');
|
||||||
|
expect(formatErrorMessage(makeError(429))).toContain('лимит');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getErrorDetails', () => {
|
||||||
|
it('возвращает объект с type, status, message', () => {
|
||||||
|
const err = makeError(404, { code: 'E_NOT_FOUND' });
|
||||||
|
const details = getErrorDetails(err);
|
||||||
|
expect(details.type).toBe(ErrorType.NOT_FOUND);
|
||||||
|
expect(details.status).toBe(404);
|
||||||
|
expect(details.code).toBe('E_NOT_FOUND');
|
||||||
|
expect(details.message).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCriticalError', () => {
|
||||||
|
it('критичны SERVER и AUTH', () => {
|
||||||
|
expect(isCriticalError(makeError(500))).toBe(true);
|
||||||
|
expect(isCriticalError(makeError(401))).toBe(true);
|
||||||
|
expect(isCriticalError(makeError(404))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getErrorAction', () => {
|
||||||
|
it('возвращает строку-рекомендацию для каждого типа', () => {
|
||||||
|
expect(getErrorAction(makeError(401))).toContain('войдите');
|
||||||
|
expect(getErrorAction(makeError(429))).toContain('Подождите');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Unit-тесты для frontend/src/utils/ipUtils.js
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidIPv4,
|
||||||
|
isValidIPv6,
|
||||||
|
isValidIP,
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIPRange,
|
||||||
|
normalizeIP,
|
||||||
|
ipToNumber,
|
||||||
|
compareIPs,
|
||||||
|
parseMask,
|
||||||
|
countAddresses,
|
||||||
|
isPrivateIPv4,
|
||||||
|
isCGNATIPv4,
|
||||||
|
getIpCategory,
|
||||||
|
formatBigInt,
|
||||||
|
ipToInt,
|
||||||
|
intToIp,
|
||||||
|
cidrToRange,
|
||||||
|
summarizeCidrs,
|
||||||
|
get16BaseCidr,
|
||||||
|
} from './ipUtils';
|
||||||
|
|
||||||
|
describe('ipUtils', () => {
|
||||||
|
describe('isValidIPv4', () => {
|
||||||
|
it('принимает валидные IPv4', () => {
|
||||||
|
expect(isValidIPv4('192.168.1.1')).toBe(true);
|
||||||
|
expect(isValidIPv4('0.0.0.0')).toBe(true);
|
||||||
|
expect(isValidIPv4('255.255.255.255')).toBe(true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
expect(isValidIPv4('256.1.1.1')).toBe(false);
|
||||||
|
expect(isValidIPv4('')).toBe(false);
|
||||||
|
expect(isValidIPv4(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIPv6', () => {
|
||||||
|
it('принимает валидные IPv6', () => {
|
||||||
|
expect(isValidIPv6('2001:db8::1')).toBe(true);
|
||||||
|
expect(isValidIPv6('::1')).toBe(true);
|
||||||
|
});
|
||||||
|
it('отклоняет IPv4', () => {
|
||||||
|
expect(isValidIPv6('192.168.1.1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIP', () => {
|
||||||
|
it('возвращает true для v4 или v6', () => {
|
||||||
|
expect(isValidIP('192.168.1.1')).toBe(true);
|
||||||
|
expect(isValidIP('::1')).toBe(true);
|
||||||
|
expect(isValidIP('invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidCIDR', () => {
|
||||||
|
it('принимает валидные CIDR', () => {
|
||||||
|
expect(isValidCIDR('192.168.0.0/24')).toBe(true);
|
||||||
|
expect(isValidCIDR('10.0.0.0/8')).toBe(true);
|
||||||
|
});
|
||||||
|
it('отклоняет невалидные', () => {
|
||||||
|
expect(isValidCIDR('192.168.0.0/33')).toBe(false);
|
||||||
|
expect(isValidCIDR('192.168.0.0')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidIPRange', () => {
|
||||||
|
it('принимает IP или CIDR', () => {
|
||||||
|
expect(isValidIPRange('192.168.1.1')).toBe(true);
|
||||||
|
expect(isValidIPRange('192.168.0.0/24')).toBe(true);
|
||||||
|
expect(isValidIPRange('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeIP', () => {
|
||||||
|
it('убирает ведущие нули в октетах', () => {
|
||||||
|
expect(normalizeIP('192.168.001.001')).toBe('192.168.1.1');
|
||||||
|
});
|
||||||
|
it('возвращает исходную строку для невалидного IP', () => {
|
||||||
|
expect(normalizeIP('invalid')).toBe('invalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ipToNumber', () => {
|
||||||
|
it('конвертирует IPv4 в число', () => {
|
||||||
|
expect(ipToNumber('0.0.0.0')).toBe(0);
|
||||||
|
expect(ipToNumber('0.0.0.1')).toBe(1);
|
||||||
|
expect(ipToNumber('255.255.255.255')).toBe(0xffffffff >>> 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareIPs', () => {
|
||||||
|
it('сравнивает два IP', () => {
|
||||||
|
expect(compareIPs('10.0.0.1', '10.0.0.2')).toBeLessThan(0);
|
||||||
|
expect(compareIPs('10.0.0.2', '10.0.0.1')).toBeGreaterThan(0);
|
||||||
|
expect(compareIPs('10.0.0.1', '10.0.0.1')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseMask', () => {
|
||||||
|
it('извлекает маску из CIDR', () => {
|
||||||
|
expect(parseMask('192.168.0.0/24')).toBe(24);
|
||||||
|
expect(parseMask('10.0.0.0/8')).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countAddresses', () => {
|
||||||
|
it('подсчитывает адреса в CIDR', () => {
|
||||||
|
expect(countAddresses(32)).toBe(1n);
|
||||||
|
expect(countAddresses(24)).toBe(256n);
|
||||||
|
expect(countAddresses(16)).toBe(65536n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPrivateIPv4', () => {
|
||||||
|
it('определяет приватные диапазоны', () => {
|
||||||
|
expect(isPrivateIPv4('10.0.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIPv4('192.168.1.1')).toBe(true);
|
||||||
|
expect(isPrivateIPv4('172.16.0.1')).toBe(true);
|
||||||
|
expect(isPrivateIPv4('8.8.8.8')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCGNATIPv4', () => {
|
||||||
|
it('определяет CGNAT 100.64.0.0/10', () => {
|
||||||
|
expect(isCGNATIPv4('100.64.0.1')).toBe(true);
|
||||||
|
expect(isCGNATIPv4('100.127.255.255')).toBe(true);
|
||||||
|
expect(isCGNATIPv4('100.63.0.1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIpCategory', () => {
|
||||||
|
it('возвращает категорию по CIDR', () => {
|
||||||
|
expect(getIpCategory('10.0.0.0/8')).toBe('private');
|
||||||
|
expect(getIpCategory('100.64.0.0/10')).toBe('cgnat');
|
||||||
|
expect(getIpCategory('8.8.8.0/24')).toBe('public');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBigInt', () => {
|
||||||
|
it('форматирует с разделителями тысяч', () => {
|
||||||
|
expect(formatBigInt(1000n)).toBe('1 000');
|
||||||
|
expect(formatBigInt(1234567n)).toBe('1 234 567');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ipToInt / intToIp', () => {
|
||||||
|
it('конвертирует туда и обратно', () => {
|
||||||
|
const ip = '192.168.1.1';
|
||||||
|
expect(intToIp(ipToInt(ip))).toBe(ip);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cidrToRange', () => {
|
||||||
|
it('возвращает [start, end] BigInt для /24', () => {
|
||||||
|
const [s, e] = cidrToRange('192.168.1.0/24');
|
||||||
|
expect(e - s + 1n).toBe(256n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('summarizeCidrs', () => {
|
||||||
|
it('объединяет смежные блоки', () => {
|
||||||
|
const result = summarizeCidrs(['192.168.0.0/24', '192.168.1.0/24']);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get16BaseCidr', () => {
|
||||||
|
it('возвращает /16 базовый блок', () => {
|
||||||
|
expect(get16BaseCidr('192.168.1.0/24')).toBe('192.168.0.0/16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.{js,ts,jsx,tsx}'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
|
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "npm test --prefix backend && npm test --prefix frontend"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user