From bac2eaae8b777cc38aa852a7f2b5ef50bc809e1b Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Wed, 22 Jul 2026 17:09:14 +0300 Subject: [PATCH] AGDNS-4277 --- client_v2/.eslintrc.cjs | 1 + client_v2/package-lock.json | 10 +- client_v2/package.json | 2 - client_v2/scripts/check-translations.js | 11 +- client_v2/scripts/translation-audit.js | 126 ++++ client_v2/src/__locales/en.json | 3 +- client_v2/src/__tests__/access-store.test.ts | 37 +- .../src/__tests__/clientForm/store.test.ts | 1 + .../src/__tests__/encryption-store.test.ts | 8 +- .../__tests__/helpers/helpers-scalar.spec.ts | 119 ++++ .../__tests__/helpers/helpers-stats.spec.ts | 199 ++++++ .../helpers/validate-domains-per-line.test.ts | 106 --- client_v2/src/__tests__/queryLogStore.test.ts | 30 + client_v2/src/api/model/dNSConfig.ts | 4 +- client_v2/src/api/model/parentalStatus200.ts | 2 +- client_v2/src/common/intl/index.ts | 19 +- client_v2/src/common/ui/Footer/Footer.tsx | 12 +- client_v2/src/common/ui/Guide/Guide.tsx | 2 +- .../common/ui/PublicHeader/PublicHeader.tsx | 7 +- .../InactivitySchedule/InactivitySchedule.tsx | 15 +- client_v2/src/components/Clients/Clients.tsx | 4 +- .../PersistentClientsTable.tsx | 24 +- .../RuntimeClientsTable.tsx | 4 +- .../src/components/Dashboard/Dashboard.tsx | 2 +- .../Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx | 26 +- .../components/Dhcp/blocks/DhcpV6Modal.tsx | 9 +- .../blocks/BlockingModeDialog.tsx | 3 +- .../Upstream/blocks/BootstrapDnsDialog.tsx | 1 + .../Upstream/blocks/FallbackDnsDialog.tsx | 3 +- .../Upstream/blocks/ServerAddressesDialog.tsx | 3 +- .../Upstream/blocks/UpstreamModeDialog.tsx | 3 +- .../src/components/DnsSettings/helpers.ts | 5 +- .../src/components/Encryption/Encryption.tsx | 6 +- .../blocks/AddTlsCert/AddTlsCertModal.tsx | 6 +- .../Encryption/blocks/ServerSettingsModal.tsx | 6 +- .../components/FilterLists/DNSRewrites.tsx | 2 +- .../ConfigureAllowlistModal.tsx | 3 +- .../ConfigureBlocklistModal.tsx | 3 +- .../ConfigureRewritesModal.tsx | 6 +- .../blocks/ListsTable/ListsTable.tsx | 7 +- .../blocks/RewritesTable/RewritesTable.tsx | 3 +- .../src/components/QueryLog/QueryLog.tsx | 12 +- client_v2/src/components/QueryLog/types.ts | 4 +- .../src/components/Settings/Settings.tsx | 6 +- .../SetupGuide/MobileConfigForm.tsx | 3 + .../src/components/UserRules/UserRules.tsx | 21 +- .../UserRules/checkResultHelpers.tsx | 3 +- client_v2/src/components/UserRules/helpers.ts | 4 +- client_v2/src/components/UserRules/types.ts | 6 +- client_v2/src/helpers/constants.ts | 108 +-- client_v2/src/helpers/form.tsx | 14 +- client_v2/src/helpers/helpers.tsx | 614 ++++++------------ client_v2/src/helpers/localStorageHelper.ts | 16 +- .../src/helpers/renderFormattedClientCell.tsx | 24 +- client_v2/src/helpers/useDebounce.ts | 19 - client_v2/src/helpers/version.ts | 2 +- client_v2/src/initialState.ts | 336 ++++------ client_v2/src/stores/access.ts | 38 +- client_v2/src/stores/clientForm.ts | 45 +- client_v2/src/stores/clients.ts | 5 +- client_v2/src/stores/dashboard.ts | 26 +- client_v2/src/stores/dhcp.ts | 14 +- client_v2/src/stores/dnsConfig.ts | 54 +- client_v2/src/stores/encryption.ts | 76 +-- client_v2/src/stores/filtering.ts | 25 +- client_v2/src/stores/install.ts | 37 +- client_v2/src/stores/queryLogs.ts | 36 +- client_v2/src/stores/rewrites.ts | 24 +- client_v2/src/stores/services.ts | 11 +- client_v2/src/stores/settings.ts | 27 +- client_v2/src/stores/stats.ts | 6 +- client_v2/src/stores/toasts.ts | 16 +- client_v2/webpack.common.js | 2 +- openapi/openapi.yaml | 19 +- 74 files changed, 1350 insertions(+), 1146 deletions(-) create mode 100644 client_v2/src/__tests__/helpers/helpers-scalar.spec.ts create mode 100644 client_v2/src/__tests__/helpers/helpers-stats.spec.ts delete mode 100644 client_v2/src/__tests__/helpers/validate-domains-per-line.test.ts delete mode 100644 client_v2/src/helpers/useDebounce.ts diff --git a/client_v2/.eslintrc.cjs b/client_v2/.eslintrc.cjs index 3c5bb7647..963b9e862 100644 --- a/client_v2/.eslintrc.cjs +++ b/client_v2/.eslintrc.cjs @@ -31,6 +31,7 @@ module.exports = { '@typescript-eslint/no-unused-vars': [ 'error', { + varsIgnorePattern: '^_', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', }, diff --git a/client_v2/package-lock.json b/client_v2/package-lock.json index c9c2106c2..1c9fb3973 100644 --- a/client_v2/package-lock.json +++ b/client_v2/package-lock.json @@ -19,7 +19,6 @@ "date-fns": "^4.1.0", "ipaddr.js": "^2.2.0", "js-yaml": "^4.1.0", - "lodash": "^4.17.19", "nanoid": "^5.1.0", "qs": "^6.14.0", "solid-js": "^1.9.0", @@ -33,7 +32,6 @@ "@solidjs/testing-library": "^0.8.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", - "@types/lodash": "^4.17.4", "@types/node": "^22.13.10", "@types/qs": "^6.15.1", "@typescript-eslint/eslint-plugin": "^8.60.1", @@ -5271,13 +5269,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -12439,6 +12430,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/client_v2/package.json b/client_v2/package.json index 65e8cf111..aba1ab017 100644 --- a/client_v2/package.json +++ b/client_v2/package.json @@ -35,7 +35,6 @@ "date-fns": "^4.1.0", "ipaddr.js": "^2.2.0", "js-yaml": "^4.1.0", - "lodash": "^4.17.19", "nanoid": "^5.1.0", "qs": "^6.14.0", "solid-js": "^1.9.0", @@ -49,7 +48,6 @@ "@solidjs/testing-library": "^0.8.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", - "@types/lodash": "^4.17.4", "@types/node": "^22.13.10", "@types/qs": "^6.15.1", "@typescript-eslint/eslint-plugin": "^8.60.1", diff --git a/client_v2/scripts/check-translations.js b/client_v2/scripts/check-translations.js index f5e6c9371..b39937913 100644 --- a/client_v2/scripts/check-translations.js +++ b/client_v2/scripts/check-translations.js @@ -8,8 +8,11 @@ import { auditTranslations, collectTranslationUsageFromFiles, formatAuditReport, + formatPluralReport, listSourceFiles, loadLocaleMessages, + loadTwoskyLocales, + validatePlurals, } from './translation-audit.js'; export const runTranslationAudit = async ({ @@ -29,7 +32,13 @@ export const runTranslationAudit = async ({ const usage = await collectTranslationUsageFromFiles(filePaths); const report = auditTranslations({ localeMessages, usage }); - write(`${formatAuditReport(report, { rootDir })}\n`); + write(`${formatAuditReport(report, { rootDir })}\n\n`); + + const repoRoot = path.resolve(rootDir, '..'); + const localesDir = path.join(srcDir, '__locales'); + const supportedLocales = await loadTwoskyLocales(repoRoot); + const pluralErrors = await validatePlurals(localesDir, supportedLocales); + write(`${formatPluralReport(pluralErrors)}\n`); return 0; } catch (error) { diff --git a/client_v2/scripts/translation-audit.js b/client_v2/scripts/translation-audit.js index 3c98f56f9..ab0bb9581 100644 --- a/client_v2/scripts/translation-audit.js +++ b/client_v2/scripts/translation-audit.js @@ -1,11 +1,60 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { validator } from '@adguard/translate'; import ts from 'typescript'; const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']); const TRANSLATION_METHODS = new Set(['getMessage', 'getPlural']); +/** + * Loads the locale-to-underscore map from .twosky.json in the repo root. + * Returns a Map where keys are hyphenated locale codes (e.g. "pt-br") and + * values are @adguard/translate locale codes (e.g. "pt_br"), or `null` for + * unsupported locales. + * + * @param {string} repoRoot - Path to the repository root (contains .twosky.json). + * @returns {Promise>} + */ +export const loadTwoskyLocales = async (repoRoot) => { + const twoskyPath = path.join(repoRoot, '.twosky.json'); + const raw = await fs.readFile(twoskyPath, 'utf8'); + const projects = JSON.parse(raw); + + const homeV2 = projects.find((p) => p.project_id === 'home_v2'); + if (!homeV2) { + throw new Error('home_v2 project not found in .twosky.json'); + } + + /** + * Converts a hyphenated twosky locale code to @adguard/translate format. + * Most become underscore (pt-br → pt_br). Special cases for locales that + * the library handles under a parent code, or doesn't handle at all. + */ + const toTranslateLocale = (code) => { + // Sinhala not in @adguard/translate plural rules table + if (code === 'si-lk') { + return null; + } + // Hong Kong & Serbian Cyrillic → parent locale + if (code === 'zh-hk') { + return 'zh'; + } + if (code === 'sr-cs') { + return 'sr'; + } + + return code.replace(/-/g, '_'); + }; + + const map = new Map(); + for (const code of Object.keys(homeV2.languages)) { + map.set(code, toTranslateLocale(code)); + } + + return map; +}; + const isIntlMethod = (node) => { if (!ts.isPropertyAccessExpression(node)) { return false; @@ -216,3 +265,80 @@ export const collectTranslationUsageFromFiles = async (filePaths) => { return usage; }; + +/** + * Validates plural forms in all locale files against the @adguard/translate + * library's plural rules (CLDR-based). Returns an array of errors grouped by + * locale file and key. + * + * @param {string} localesDir - Path to __locales directory + * @param {{write?: (chunk: string) => void}} [options] + * @returns {Promise>} + */ +export const validatePlurals = async (localesDir, supportedLocales) => { + const errors = []; + const entries = await fs.readdir(localesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + + const localeName = entry.name.replace(/\.json$/, ''); + const localeCode = supportedLocales.get(localeName); + + // `undefined` means not in twosky list; `null` means unsupported + if (localeCode === undefined || localeCode === null) { + continue; + } + + const localePath = path.join(localesDir, entry.name); + const messages = await loadLocaleMessages(localePath); + + for (const [key, value] of Object.entries(messages)) { + // Only validate strings that START with "|" (plural form separator). + // Strings containing "||" (like filter rule examples) are not plurals. + if (typeof value !== 'string' || !value.trimStart().startsWith('|')) { + continue; + } + + if (!validator.isPluralFormValid(value, localeCode, key)) { + errors.push({ file: entry.name, key, value }); + } + } + } + + return errors; +}; + +/** + * Formats the plural validation report. + * + * @param {Array<{file: string, key: string, value: string}>} errors + * @returns {string} + */ +export const formatPluralReport = (errors) => { + if (errors.length === 0) { + return 'Plural form validation: all locales passed'; + } + + const byFile = new Map(); + for (const err of errors) { + const list = byFile.get(err.file) || []; + list.push(err); + byFile.set(err.file, list); + } + + const lines = [`Plural form errors: ${errors.length} keys`]; + + for (const [file, errs] of byFile) { + lines.push(`\n ${file}:`); + for (const { key, value } of errs) { + const pipeCount = (value.match(/\|/g) || []).length; + lines.push(` ${key} (${pipeCount} pipes)`); + lines.push(` "${value.slice(0, 80)}${value.length > 80 ? '…' : ''}"`); + } + } + + return lines.join('\n'); +}; diff --git a/client_v2/src/__locales/en.json b/client_v2/src/__locales/en.json index c78d6eb14..9161d79d8 100644 --- a/client_v2/src/__locales/en.json +++ b/client_v2/src/__locales/en.json @@ -208,7 +208,8 @@ "dns_blocking_mode_title": "Blocking mode", "dns_blocking_mode_ttl_label": "Blocked response TTL, in seconds", "dns_blocking_mode_ttl_placeholder": "Enter TTL", - "dns_bootstrap_dns_desc": "Sets the DNS servers used to resolve DoH/DoT upstream resolver hostnames", + "dns_bootstrap_dns_desc": "Sets the DNS servers used to resolve the hostnames of DoH/DoT upstream resolvers", + "dns_bootstrap_dns_desc_2": "Enter one IP address per line. Comments must start on a new line with #", "dns_bootstrap_dns_label": "Bootstrap DNS servers, one per line", "dns_bootstrap_dns_placeholder": "IP addresses", "dns_bootstrap_dns_title": "Bootstrap DNS servers", diff --git a/client_v2/src/__tests__/access-store.test.ts b/client_v2/src/__tests__/access-store.test.ts index 106a28578..c77cacfb5 100644 --- a/client_v2/src/__tests__/access-store.test.ts +++ b/client_v2/src/__tests__/access-store.test.ts @@ -16,7 +16,7 @@ vi.mock('panel/stores/toasts', () => ({ addErrorToast: mocks.addErrorToast, })); -import { toggleClientBlock } from 'panel/stores/access'; +import { toggleClientBlock, setAccessList } from 'panel/stores/access'; describe('toggleClientBlock', () => { beforeEach(() => vi.clearAllMocks()); @@ -77,3 +77,38 @@ describe('toggleClientBlock', () => { }); }); }); + +describe('setAccessList', () => { + beforeEach(() => vi.clearAllMocks()); + + it('splits newline-delimited strings into arrays', async () => { + await setAccessList({ + allowed_clients: '1.1.1.1\n2.2.2.2', + disallowed_clients: '3.3.3.3', + blocked_hosts: 'badhost.com\nevil.com', + }); + expect(mocks.accessSet).toHaveBeenCalledWith({ + allowed_clients: ['1.1.1.1', '2.2.2.2'], + disallowed_clients: ['3.3.3.3'], + blocked_hosts: ['badhost.com', 'evil.com'], + }); + }); + + it('handles single values correctly', async () => { + await setAccessList({ allowed_clients: '1.1.1.1' }); + expect(mocks.accessSet).toHaveBeenCalledWith({ + allowed_clients: ['1.1.1.1'], + disallowed_clients: undefined, + blocked_hosts: undefined, + }); + }); + + it('handles empty values', async () => { + await setAccessList({ allowed_clients: '' }); + expect(mocks.accessSet).toHaveBeenCalledWith({ + allowed_clients: [], + disallowed_clients: undefined, + blocked_hosts: undefined, + }); + }); +}); diff --git a/client_v2/src/__tests__/clientForm/store.test.ts b/client_v2/src/__tests__/clientForm/store.test.ts index 0dac70f28..ea2ee1a3f 100644 --- a/client_v2/src/__tests__/clientForm/store.test.ts +++ b/client_v2/src/__tests__/clientForm/store.test.ts @@ -72,6 +72,7 @@ describe('clientForm store', () => { duckduckgo: false, yandex: false, pixabay: false, + ecosia: false, }, }); expect(clientFormState.safe_search.enabled).toBe(true); diff --git a/client_v2/src/__tests__/encryption-store.test.ts b/client_v2/src/__tests__/encryption-store.test.ts index 24b0934eb..b97ae953a 100644 --- a/client_v2/src/__tests__/encryption-store.test.ts +++ b/client_v2/src/__tests__/encryption-store.test.ts @@ -44,9 +44,9 @@ describe('setTlsConfig', () => { await setTlsConfig({ certificate_chain: '', private_key: '', - port_https: '', - port_dns_over_tls: '', - port_dns_over_quic: '', + port_https: 0, + port_dns_over_tls: 0, + port_dns_over_quic: 0, }); const sent = mocks.tlsConfigure.mock.calls[0][0]; expect(sent.port_https).toBe(0); @@ -89,7 +89,7 @@ describe('setTlsConfig', () => { expect(encryptionState.warning_validation).toBe(''); expect(encryptionState.subject).toBe(''); expect(encryptionState.issuer).toBe(''); - expect(encryptionState.key_type).toBe(''); + expect(encryptionState.key_type).toBeUndefined(); expect(encryptionState.dns_names).toBeNull(); }); diff --git a/client_v2/src/__tests__/helpers/helpers-scalar.spec.ts b/client_v2/src/__tests__/helpers/helpers-scalar.spec.ts new file mode 100644 index 000000000..d1b9424c6 --- /dev/null +++ b/client_v2/src/__tests__/helpers/helpers-scalar.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + msToSeconds, + msToMinutes, + msToHours, + secondsToMilliseconds, + splitByNewLine, + trimLinesAndRemoveEmpty, + normalizeRulesTextarea, + captitalizeWords, + getWebAddress, + getInterfaceIp, + isIpInCidr, + parseSubnetMask, + subnetMaskToBitMask, +} from '../../helpers/helpers'; + +describe('ms helpers', () => { + it('converts ms -> seconds', () => { + expect(msToSeconds(1500)).toBe(1); + }); + it('converts ms -> minutes', () => { + expect(msToMinutes(120_000)).toBe(2); + }); + it('converts ms -> hours', () => { + expect(msToHours(3_600_000)).toBe(1); + }); +}); + +describe('secondsToMilliseconds', () => { + it('multiplies by 1000', () => { + expect(secondsToMilliseconds(3)).toBe(3000); + }); + it('returns falsy input as-is', () => { + expect(secondsToMilliseconds(0)).toBe(0); + // The current implementation returns `seconds` unchanged if falsy + expect(secondsToMilliseconds(undefined as unknown as number)).toBe(undefined); + }); +}); + +describe('splitByNewLine', () => { + it('splits and removes empty lines', () => { + expect(splitByNewLine('a\nb\n\nc')).toStrictEqual(['a', 'b', 'c']); + }); + it('returns [] for falsy input', () => { + expect(splitByNewLine('')).toStrictEqual([]); + expect(splitByNewLine(undefined as unknown as string)).toStrictEqual([]); + }); +}); + +describe('trimLinesAndRemoveEmpty', () => { + it('trims lines', () => { + expect(trimLinesAndRemoveEmpty(' a \n\n b ')).toBe('a\nb'); + }); +}); + +describe('normalizeRulesTextarea', () => { + it('strips leading newlines and collapses repeated blank lines', () => { + expect(normalizeRulesTextarea('\na\n\nb')).toBe('a\nb'); + }); +}); + +describe('captitalizeWords', () => { + it('capitalizes each word splitting by space, dash, or underscore', () => { + expect(captitalizeWords('safe_browsing mode-test')).toBe('Safe Browsing Mode Test'); + }); +}); + +describe('getWebAddress', () => { + it('builds http url omitting standard port 80', () => { + expect(getWebAddress('192.168.1.1', 80)).toBe('http://192.168.1.1'); + }); + it('appends non-standard port', () => { + expect(getWebAddress('192.168.1.1', 8080)).toBe('http://192.168.1.1:8080'); + }); + it('brackets IPv6 with zone encoding', () => { + expect(getWebAddress('fe80::1%eth0', 80)).toBe('http://[fe80::1%25eth0]'); + }); +}); + +describe('getInterfaceIp', () => { + it('prefers IPv4 over IPv6', () => { + expect(getInterfaceIp({ ip_addresses: ['10.0.0.1', 'fe80::1'] })).toBe('10.0.0.1'); + }); + it('skips IPv6 link-local when IPv4 present', () => { + expect(getInterfaceIp({ ip_addresses: ['192.168.1.1', 'fe80::1'] })).toBe('192.168.1.1'); + }); + it('falls back to IPv6 global without zone', () => { + expect(getInterfaceIp({ ip_addresses: ['2001:db8::1'] })).toBe('2001:db8::1'); + }); + it('returns undefined when no addresses', () => { + expect(getInterfaceIp({ ip_addresses: [] })).toBeUndefined(); + }); +}); + +describe('isIpInCidr', () => { + it('matches IP inside CIDR', () => { + expect(isIpInCidr('192.168.1.5', '192.168.1.0/24')).toBe(true); + }); + it('rejects IP outside CIDR', () => { + expect(isIpInCidr('10.0.0.1', '192.168.1.0/24')).toBe(false); + }); +}); + +describe('parseSubnetMask', () => { + it('returns prefix length for valid mask', () => { + expect(parseSubnetMask('255.255.255.0')).toBe(24); + }); + it('returns null for invalid mask string', () => { + expect(parseSubnetMask('not-a-mask')).toBeNull(); + }); +}); + +describe('subnetMaskToBitMask', () => { + it('computes prefix length from dotted mask', () => { + expect(subnetMaskToBitMask('255.255.255.0')).toBe(24); + }); +}); diff --git a/client_v2/src/__tests__/helpers/helpers-stats.spec.ts b/client_v2/src/__tests__/helpers/helpers-stats.spec.ts new file mode 100644 index 000000000..2f3eec624 --- /dev/null +++ b/client_v2/src/__tests__/helpers/helpers-stats.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeTopStats, + addClientInfo, + normalizeTopClients, + normalizeFilters, + normalizeFilteringStatus, + getParamsForClientsSearch, + checkFiltered, + checkBlockedService, + getPathWithQueryString, + getSpecialFilterName, + getServiceName, + normalizeWhois, + normalizeLogs, +} from '../../helpers/helpers'; +import type { ClientsFindEntry } from '../../api/model/clientsFindEntry'; +import type { FilteringReason } from '../../api/model/filteringReason'; + +describe('normalizeTopStats', () => { + it('converts {name -> count} objects to {name, count} array', () => { + expect(normalizeTopStats([{ '192.168.1.1': 42 }, { 'example.com': 5 }])).toStrictEqual([ + { name: '192.168.1.1', count: 42 }, + { name: 'example.com', count: 5 }, + ]); + }); +}); + +describe('addClientInfo', () => { + it('resolves client info by param key', () => { + const data = [{ name: '192.168.1.1', count: 1 }]; + const clients: ClientsFindEntry[] = [{ '192.168.1.1': { name: 'MyPhone' } }]; + expect(addClientInfo(data, clients, 'name')).toStrictEqual([ + { name: '192.168.1.1', count: 1, info: { name: 'MyPhone' } }, + ]); + }); +}); + +describe('normalizeTopClients', () => { + it('splits into auto/configured by name and info', () => { + const r = normalizeTopClients([ + { name: '192.168.1.1', count: 7, info: { name: 'MyPhone' } }, + ]); + expect(r.auto).toStrictEqual({ '192.168.1.1': 7 }); + expect(r.configured).toStrictEqual({ MyPhone: 7 }); + }); +}); + +describe('normalizeFilters', () => { + it('maps snake_case API fields to camelCase with defaults', () => { + expect( + normalizeFilters([ + { + id: 1, + url: 'http://example.com/list.txt', + enabled: true, + last_updated: '2024-01-01', + name: 'My List', + rules_count: 100, + }, + ]), + ).toStrictEqual([ + { + id: 1, + url: 'http://example.com/list.txt', + enabled: true, + lastUpdated: '2024-01-01', + name: 'My List', + rulesCount: 100, + }, + ]); + }); + it('returns [] for falsy input', () => { + expect(normalizeFilters(undefined)).toStrictEqual([]); + }); +}); + +describe('normalizeFilteringStatus', () => { + it('normalizes full status with user_rules', () => { + const r = normalizeFilteringStatus({ + enabled: true, + filters: [], + whitelist_filters: [], + user_rules: ['rule1', 'rule2'], + interval: 24, + }); + expect(r.enabled).toBe(true); + expect(r.interval).toBe(24); + expect(r.userRules).toBe('rule1\nrule2'); + }); +}); + +describe('getParamsForClientsSearch', () => { + it('collects unique client ids from TopStat[]', () => { + expect( + getParamsForClientsSearch( + [ + { name: 'client-a', count: 1 }, + { name: 'client-b', count: 2 }, + ], + 'name', + ), + ).toStrictEqual({ clients: [{ id: 'client-a' }, { id: 'client-b' }] }); + }); + it('includes additional param when provided', () => { + const r = getParamsForClientsSearch([{ name: 'a', count: 1 }], 'name', 'count'); + expect(r.clients).toStrictEqual([{ id: 'a' }, { id: 1 }]); + }); +}); + +describe('checkFiltered / checkBlockedService', () => { + it('checkFiltered returns true for Filtered* reasons', () => { + expect(checkFiltered('FilteredBlackList' as FilteringReason)).toBe(true); + }); + it('checkFiltered returns false for NotFiltered* reasons', () => { + expect(checkFiltered('NotFilteredNotFound' as FilteringReason)).toBe(false); + }); + it('checkBlockedService returns true for FilteredBlockedService', () => { + expect(checkBlockedService('FilteredBlockedService' as FilteringReason)).toBe(true); + }); +}); + +describe('getPathWithQueryString', () => { + it('serializes params, skips empty/undefined values, repeats arrays', () => { + const r = getPathWithQueryString('/endpoint', { + a: '1', + b: '', + c: undefined, + d: ['x', 'y'], + }); + expect(r).toBe('/endpoint?a=1&d=x&d=y'); + }); + it('handles null params gracefully', () => { + const r = getPathWithQueryString('/p', { a: '1', b: null }); + expect(r).toBe('/p?a=1'); + }); + it('handles undefined params arg', () => { + expect(getPathWithQueryString('/p', undefined)).toBe('/p?'); + }); +}); + +describe('getSpecialFilterName', () => { + it('returns localized name for known special filter IDs', () => { + expect(typeof getSpecialFilterName(0)).toBe('string'); + expect(typeof getSpecialFilterName(-1)).toBe('string'); + expect(typeof getSpecialFilterName(-5)).toBe('string'); + }); +}); + +describe('getServiceName', () => { + it('returns name for matching service id', () => { + expect(getServiceName([{ id: 'svc1', name: 'My Service' }], 'svc1')).toBe('My Service'); + }); + it('returns undefined for unknown id', () => { + expect(getServiceName([{ id: 'svc1', name: 'My Service' }], 'svc-unknown')).toBeUndefined(); + }); +}); + +describe('normalizeWhois', () => { + it('derives location from city and country', () => { + expect(normalizeWhois({ city: 'NY', country: 'US', orgname: 'Example' })).toMatchObject({ + location: 'US, NY', + orgname: 'Example', + }); + }); + it('uses only country when city absent', () => { + expect(normalizeWhois({ country: 'DE', orgname: 'Org' })).toMatchObject({ + location: 'DE', + }); + }); + it('returns placeholder defaults for empty whois', () => { + expect(normalizeWhois({})).toMatchObject({ + location: 'New York, US', + orgname: 'Example Organization', + }); + }); +}); + +describe('normalizeLogs', () => { + it('maps query log item to normalized shape', () => { + const [item] = normalizeLogs([ + { + time: '2024-01-01T00:00:00Z', + question: { + name: 'example.com', + unicode_name: 'example.com', + type: 'A', + }, + answer: [{ value: '1.2.3.4', type: 'A', ttl: 60 }], + status: 'processed', + }, + ]); + expect(item.domain).toBe('example.com'); + expect(item.unicodeName).toBe('example.com'); + expect(item.type).toBe('A'); + expect(item.response).toStrictEqual([{ value: '1.2.3.4', type: 'A', ttl: 60 }]); + }); +}); diff --git a/client_v2/src/__tests__/helpers/validate-domains-per-line.test.ts b/client_v2/src/__tests__/helpers/validate-domains-per-line.test.ts deleted file mode 100644 index e54610866..000000000 --- a/client_v2/src/__tests__/helpers/validate-domains-per-line.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('panel/common/intl', () => ({ - default: { - getMessage: vi.fn((key: string, values?: Record) => { - if (key === 'form_error_format_line') { - return `Invalid format on line ${values?.line}`; - } - if (key === 'form_error_format_lines') { - return `Invalid format on lines ${values?.lines}`; - } - if (key === 'form_error_format') { - return 'Invalid format'; - } - return key; - }), - }, -})); - -import { validateDomainsPerLine } from 'panel/helpers/validators'; - -describe('validateDomainsPerLine', () => { - it('returns undefined for empty string', () => { - expect(validateDomainsPerLine('')).toBeUndefined(); - }); - - it('returns undefined for plain domain', () => { - expect(validateDomainsPerLine('example.org')).toBeUndefined(); - }); - - it('returns undefined for wildcard domain', () => { - expect(validateDomainsPerLine('*.example.org')).toBeUndefined(); - }); - - it('returns undefined for AdGuard URL filter rule', () => { - expect(validateDomainsPerLine('||example.org^')).toBeUndefined(); - }); - - it('returns undefined for regex pattern', () => { - expect(validateDomainsPerLine('/regex.pattern/')).toBeUndefined(); - }); - - it('returns undefined for comment line', () => { - expect(validateDomainsPerLine('# this is a comment')).toBeUndefined(); - }); - - it('rejects !-prefixed filter rule that would otherwise pass dot check', () => { - expect(validateDomainsPerLine('! ||example.org^')).toBeTruthy(); - }); - - it('rejects only ! lines as invalid', () => { - expect(validateDomainsPerLine('! first\n! second')).toBeTruthy(); - }); - - it('returns undefined for mixed valid lines with comments', () => { - expect( - validateDomainsPerLine('# comment\nexample.org\n||ads.example.org^'), - ).toBeUndefined(); - }); - - it('returns "Invalid format" for entry without dot', () => { - expect(validateDomainsPerLine('notadomain')).toBe('Invalid format'); - }); - - it('returns "Invalid format on line N" when specific line has no dot', () => { - expect(validateDomainsPerLine('example.org\nnodot')).toBe('Invalid format on line 2'); - }); - - it('returns "Invalid format on lines N, M" when multiple lines invalid', () => { - expect(validateDomainsPerLine('nodot1\nexample.org\nnodot2')).toBe( - 'Invalid format on lines 1, 3', - ); - }); - - it('returns "Invalid format" for single invalid line with trailing newline', () => { - expect(validateDomainsPerLine('notadomain\n')).toBe('Invalid format'); - }); - - it('returns "Invalid format" for single invalid line with leading newline', () => { - expect(validateDomainsPerLine('\nnotadomain')).toBe('Invalid format'); - }); - - it('returns "Invalid format on lines 1, 2" when both lines invalid', () => { - expect(validateDomainsPerLine('nodot1\nnodot2')).toBe('Invalid format on lines 1, 2'); - }); - - it('returns "Invalid format on line 2" when second line invalid in multi-content input', () => { - expect(validateDomainsPerLine('example.org\nnodot')).toBe('Invalid format on line 2'); - }); - - it('returns undefined for all-blank input', () => { - expect(validateDomainsPerLine('\n\n')).toBeUndefined(); - }); - - it('handles blank line between two invalid lines', () => { - expect(validateDomainsPerLine('nodot1\n\nnodot2')).toBe('Invalid format on lines 1, 3'); - }); - - it('returns "Invalid format" for comment-then-invalid (one content line)', () => { - expect(validateDomainsPerLine('# comment\nnotadomain')).toBe('Invalid format'); - }); - - it('returns "Invalid format" for invalid-then-comment (one content line)', () => { - expect(validateDomainsPerLine('notadomain\n# comment')).toBe('Invalid format'); - }); -}); diff --git a/client_v2/src/__tests__/queryLogStore.test.ts b/client_v2/src/__tests__/queryLogStore.test.ts index d9e221b6d..902936ba4 100644 --- a/client_v2/src/__tests__/queryLogStore.test.ts +++ b/client_v2/src/__tests__/queryLogStore.test.ts @@ -113,4 +113,34 @@ describe('queryLogs store', () => { expect(queryLog).toHaveBeenCalledTimes(2); expect(queryLogsState.processingGetLogs).toBe(false); }); + + it('always sends limit=20 to prevent loading all records at once', async () => { + (queryLog as any).mockReset(); + (queryLog as any) + .mockResolvedValueOnce({ + data: Array.from({ length: 20 }, () => ({ reason: 'Rewrite', question: {} })), + oldest: 'cursor1', + }) + .mockResolvedValueOnce({ + data: [{ reason: 'Rewrite', question: {} }], + oldest: '', + }); + + await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' }); + + for (const call of (queryLog as any).mock.calls) { + expect(call[0]).toHaveProperty('limit', 20); + } + expect(queryLog).not.toHaveBeenCalledWith(expect.not.objectContaining({ limit: 20 })); + + (queryLog as any).mockReset(); + (queryLog as any).mockResolvedValueOnce({ + data: [{ reason: 'Rewrite', question: {} }], + oldest: '', + }); + + await getAdditionalLogs(); + + expect(queryLog).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 })); + }); }); diff --git a/client_v2/src/api/model/dNSConfig.ts b/client_v2/src/api/model/dNSConfig.ts index a26005a45..354a034b1 100644 --- a/client_v2/src/api/model/dNSConfig.ts +++ b/client_v2/src/api/model/dNSConfig.ts @@ -19,13 +19,13 @@ export interface DNSConfig { * @minimum 0 * @maximum 32 */ - ratelimit_subnet_subnet_len_ipv4?: number; + ratelimit_subnet_len_ipv4?: number; /** * Length of the subnet mask for IPv6 addresses. * @minimum 0 * @maximum 128 */ - ratelimit_subnet_subnet_len_ipv6?: number; + ratelimit_subnet_len_ipv6?: number; /** List of IP addresses excluded from rate limiting. */ ratelimit_whitelist?: string[]; blocking_mode?: DNSConfigBlockingMode; diff --git a/client_v2/src/api/model/parentalStatus200.ts b/client_v2/src/api/model/parentalStatus200.ts index a2b6876d3..465fbc013 100644 --- a/client_v2/src/api/model/parentalStatus200.ts +++ b/client_v2/src/api/model/parentalStatus200.ts @@ -1,4 +1,4 @@ export type ParentalStatus200 = { - enable?: boolean; + enabled?: boolean; sensitivity?: number; }; diff --git a/client_v2/src/common/intl/index.ts b/client_v2/src/common/intl/index.ts index ab808bb3f..b79e500de 100644 --- a/client_v2/src/common/intl/index.ts +++ b/client_v2/src/common/intl/index.ts @@ -1,6 +1,6 @@ import { createSignal } from 'solid-js'; -import { I18nInterface, translate } from '@adguard/translate'; +import { I18nInterface, Locale, translate } from '@adguard/translate'; import { BASE_LOCALE } from 'panel/helpers/twosky'; import en from 'panel/__locales/en.json'; @@ -38,6 +38,19 @@ const LOCALES = { const messages: LocalesTypes = LOCALES; +/** + * Converts a hyphenated twosky locale code to the underscore format that + * {@link https://github.com/AdguardTeam/translate @adguard/translate} + * expects for plural-form lookups (e.g. pt-br → pt_br). + */ +const toTranslateLocale = (code: string): Locale => { + // zh-hk / sr-cs → parent locale + if (code === 'zh-hk') return 'zh' as Locale; + if (code === 'sr-cs') return 'sr' as Locale; + + return code.replace(/-/g, '_') as Locale; +}; + const resolveLanguage = (lng: string): LocalesType => { const l = lng.toLowerCase(); @@ -74,7 +87,7 @@ export const i18n = (lang: LocalesType) => { const resolved = resolveLanguage(lang); return { getMessage: (key: string) => messages[resolved]?.[key] || '', - getUILanguage: () => resolved, + getUILanguage: () => toTranslateLocale(resolved), getBaseMessage: (key: string) => messages.en![key] || key, getBaseUILanguage: () => BASE_LOCALE as LocalesType, }; @@ -82,7 +95,7 @@ export const i18n = (lang: LocalesType) => { const detectedLanguage = ((typeof window !== 'undefined' && typeof localStorage !== 'undefined' && - LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.LANGUAGE)) || + LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.LANGUAGE)) || (typeof navigator !== 'undefined' && (navigator.language as string)) || BASE_LOCALE) as LocalesType; diff --git a/client_v2/src/common/ui/Footer/Footer.tsx b/client_v2/src/common/ui/Footer/Footer.tsx index 1c643cc1a..f553e6b40 100644 --- a/client_v2/src/common/ui/Footer/Footer.tsx +++ b/client_v2/src/common/ui/Footer/Footer.tsx @@ -4,7 +4,7 @@ import cn from 'clsx'; import theme from 'panel/lib/theme'; import { Dropdown } from 'panel/common/ui/Dropdown'; import { Icon } from 'panel/common/ui/Icon'; -import intl, { LocalesType } from 'panel/common/intl'; +import intl from 'panel/common/intl'; import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper'; import { LanguageDropdown } from '../LanguageDropdown/LanguageDropdown'; @@ -19,6 +19,8 @@ import { import { dashboardState } from 'panel/stores/dashboard'; import s from './styles.module.pcss'; +import { Lang } from 'panel/api/model/lang'; +import { ProfileInfoTheme } from 'panel/api/model/profileInfoTheme'; export const Footer = () => { const currentTheme = () => dashboardState.theme || THEMES.auto; @@ -50,7 +52,7 @@ export const Footer = () => { return 'theme_light'; }; - const changeLanguage = async (newLang: LocalesType) => { + const changeLanguage = async (newLang: Lang) => { setHtmlLangAttr(newLang); try { await changeLanguageAction(newLang); @@ -61,7 +63,7 @@ export const Footer = () => { } }; - const onThemeChange = (value: string) => { + const onThemeChange = (value: ProfileInfoTheme) => { if (isLoggedIn()) { changeTheme(value); } else { @@ -127,7 +129,7 @@ export const Footer = () => { onOpenChange={setThemeDropdownOpen} menu={
- + {(v) => (