mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-23 14:25:01 +03:00
89e200ead4
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.
261 lines
8.6 KiB
TypeScript
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();
|
|
});
|
|
});
|