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
|
||||
|
||||
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:
|
||||
name: Build and push image
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
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": {
|
||||
"start": "node server.js",
|
||||
"dev": "node_modules\\.bin\\nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"repository": {
|
||||
"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:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -36,7 +38,8 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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 react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.{js,ts,jsx,tsx}'],
|
||||
globals: false,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user