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

This commit is contained in:
2026-02-12 11:20:36 +07:00
parent 6b8e060d54
commit 2dfb6916dd
12 changed files with 2261 additions and 151 deletions
+18
View File
@@ -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:
+69
View File
@@ -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
+1 -1
View File
@@ -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",
+92
View File
@@ -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'));
});
});
});
+61
View File
@@ -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);
});
});
});
+179
View File
@@ -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('&lt;'));
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);
});
});
});
+1533 -148
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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"
+121
View File
@@ -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('Подождите');
});
});
});
+176
View File
@@ -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');
});
});
});
+6
View File
@@ -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
View File
@@ -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": "",