Files
3x-ui/frontend/src/test/geo-browser-selection.test.tsx
T
MHSanaei 89e200ead4 fix(frontend): key geo entries by page position and clear test-suite noise
Zod 4: use the `error` param instead of the deprecated `message`.
lint:deprecated missed these because tsgolint's no-deprecated does not
resolve object-literal properties on a `string | Params` union.

Geodata: key geo entry rows by page position. antd deprecates rowKey's
index argument, and kind:value repeats within a page because the reader
drops domain attributes (22 pairs in geosite_IR.dat, 108 in geosite_RU).

Nord/PIA: the "All cities/regions" option used a null value, which antd
warns on. Map it through a sentinel at the Select boundary so form state
stays null, with tests that fail when the sentinel is not mapped back.

Tests:
- Run the oxlint guard through node; .bin/oxlint is a sh shim Windows
  cannot spawn, and the swallowed error left both guard cases vacuous.
- Start unit workers with --no-experimental-webstorage; msw's localStorage
  probe made Node 25+ warn once per forked worker.
- Set IS_REACT_ACT_ENVIRONMENT, which RTL never sets with globals: false,
  and settle the async updates it exposed inside act(). The row-cells
  memo test now fails when memo is removed.
- Disable antd's click wave in Storybook; it re-rendered inside the next
  story's act() and tripped "not configured to support act".
- Assert InboundFormModal's validation log instead of leaking it, and
  give the rule-form test a well-formed clients/list response.
2026-09-25 21:21:03 +02:00

261 lines
8.6 KiB
TypeScript

import type { ReactNode } from 'react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
import GeoTokenInput from '@/components/geodata/GeoTokenInput';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
const FILES = [
{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 },
];
const IP_FILE = {
name: 'geoip.dat',
kind: 'ip',
size: 2048,
modifiedAt: 1785428467270,
categories: 1,
};
const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
const CATEGORIES = {
total: 3,
items: [
{ code: 'cn', entries: 2, attributes: [] },
{ code: 'google', entries: 2, attributes: ['ads'] },
{ code: 'telegram', entries: 1, attributes: [] },
],
};
function mockGeodata(files: unknown[] = FILES) {
const get = vi
.spyOn(HttpUtil, 'get')
.mockImplementation(async (url: string, params?: unknown) => {
const requestedFile = (params as { file?: string } | undefined)?.file;
if (url.includes('/geodata/files')) return new Msg(true, '', files);
if (url.includes('/geodata/categories')) {
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
}
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
return new Msg(true, '', null);
});
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
return get;
}
type GetSpy = ReturnType<typeof mockGeodata>;
function entryFilters(get: GetSpy): string[] {
return get.mock.calls
.filter(([url]) => String(url).includes('/geodata/entries'))
.map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
}
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
}
async function checkboxFor(code: string) {
const cell = await screen.findByText(code);
const row = cell.closest('.ant-table-row');
if (!row) throw new Error(`row for ${code} not found`);
return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
}
describe('GeoBrowserModal selection', () => {
it('seeds the selection from the field every time it opens', async () => {
mockGeodata();
const view = render(
<GeoBrowserModal
open
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
view.rerender(
<GeoBrowserModal
open={false}
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
);
view.rerender(
<GeoBrowserModal
open
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
expect((await checkboxFor('cn')).checked).toBe(false);
});
it('keeps selections that the search box has filtered out of view', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await checkboxFor('google'));
await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
await waitFor(() => expect(screen.queryByText('google')).toBeNull());
await user.click(await checkboxFor('cn'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledTimes(1);
const applied = String(onApply.mock.calls[0][0]);
expect(
applied
.split(',')
.map((token) => token.trim())
.sort(),
).toEqual(['geosite:cn', 'geosite:google']);
});
it('drops a category from the field when its checkbox is cleared', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(
<GeoBrowserModal
open
kind="site"
value="google.com, geosite:google, geosite:blabla"
onApply={onApply}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
await user.click(await checkboxFor('google'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
});
it('offers only databases matching the field kind', async () => {
mockGeodata([...FILES, IP_FILE]);
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await screen.findByText('private');
expect(screen.getByTitle('geoip.dat')).toBeTruthy();
expect(screen.queryByText('google')).toBeNull();
});
it('does not seed one database from another database categories', async () => {
mockGeodata([...FILES, IP_FILE]);
const user = userEvent.setup();
render(
<GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
await user.click(await checkboxFor('google'));
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
expect(screen.queryByText('private')).toBeNull();
});
it('waits for the entry filter to settle instead of querying every keystroke', async () => {
const get = mockGeodata();
const user = userEvent.setup({ delay: null });
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await screen.findByText('cn'));
await waitFor(() => expect(entryFilters(get)).toEqual(['']));
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
expect(entryFilters(get)).toEqual(['']);
await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
});
it('drops the pending filter when another category is opened', async () => {
const get = mockGeodata();
const user = userEvent.setup({ delay: null });
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await screen.findByText('cn'));
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
await user.click(screen.getByText('telegram'));
await act(() => new Promise((resolve) => setTimeout(resolve, 800)));
expect(entryFilters(get)).toEqual(['', '']);
});
it('ticks and unticks a category written in its long ext form', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(
<GeoBrowserModal
open
kind="site"
value="ext:geosite.dat:cn, google.com"
onApply={onApply}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
await user.click(await checkboxFor('cn'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledWith('google.com');
});
});
describe('GeoTokenInput validation feedback', () => {
it('says the check failed instead of dropping the warnings silently', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
vi.spyOn(HttpUtil, 'post')
.mockResolvedValueOnce(
new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]),
)
.mockResolvedValue(new Msg(false, 'too many tokens'));
const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
await screen.findByText(
'Could not check these values against the geo databases',
{},
{ timeout: 3000 },
);
expect(screen.queryByText(/Not in the database/)).toBeNull();
});
});