mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 00:31:00 +00:00
fix(frontend): preserve edited server drafts (#6156)
* fix(frontend): preserve edited server drafts * fix(frontend): retain Xray server projections * fix(frontend): keep draft controls internal * fix(frontend): rehydrate saved redacted settings * fix(frontend): order saved draft hydration * fix(frontend): preserve draft baselines on security saves --------- Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
@@ -25,4 +26,80 @@ describe('useAllSettings', () => {
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
||||
});
|
||||
|
||||
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||
const values = [
|
||||
{ webPort: 2053 },
|
||||
{ webPort: 2054 },
|
||||
];
|
||||
let index = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ webPort: 3000 }));
|
||||
await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.allSetting.webPort).toBe(3000);
|
||||
expect(result.current.saveDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('hydrates redacted secrets after a successful save', async () => {
|
||||
let fetchCount = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||
await act(async () => {
|
||||
await result.current.saveAll();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('establishes a saved baseline for a full-payload security save', async () => {
|
||||
let fetchCount = 0;
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/setting/all') {
|
||||
fetchCount += 1;
|
||||
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
|
||||
}
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useAllSettings(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
|
||||
await act(async () => {
|
||||
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
|
||||
expect(result.current.allSetting.tgBotToken).toBe('');
|
||||
expect(result.current.allSetting.hasTgBotToken).toBe(true);
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useXraySetting } from '@/hooks/useXraySetting';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
function xrayPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
xraySetting: {},
|
||||
inboundTags: [],
|
||||
clientReverseTags: [],
|
||||
outboundTestUrl: 'https://test.example',
|
||||
subscriptionOutbounds: [],
|
||||
subscriptionOutboundTags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
});
|
||||
|
||||
describe('useXraySetting', () => {
|
||||
it('refreshes server-derived outbounds while the editor is dirty', async () => {
|
||||
let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.setXraySetting('{"outbounds":[]}'));
|
||||
payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
|
||||
await act(async () => result.current.fetchAll());
|
||||
|
||||
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
|
||||
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
|
||||
});
|
||||
|
||||
it('keeps the outbound test URL input empty when it is cleared', async () => {
|
||||
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.setOutboundTestUrl(''));
|
||||
|
||||
expect(result.current.outboundTestUrl).toBe('');
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||
|
||||
describe('useServerDraft', () => {
|
||||
it('keeps an edited draft when the server refetches', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft({ value: 'edited' }));
|
||||
rerender({ server: { value: 'two' } });
|
||||
|
||||
expect(result.current.draft).toEqual({ value: 'edited' });
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a refetch that matches the saved draft', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft({ value: 'saved' }));
|
||||
rerender({ server: { value: 'saved' } });
|
||||
|
||||
expect(result.current.draft).toEqual({ value: 'saved' });
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('compares a preserved draft with the latest server value', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft({ value: 'later' }));
|
||||
rerender({ server: { value: 'saved' } });
|
||||
act(() => result.current.setDraft({ value: 'one' }));
|
||||
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates clean drafts under StrictMode', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{
|
||||
initialProps: { server: { value: 'one' } },
|
||||
wrapper: StrictMode,
|
||||
},
|
||||
);
|
||||
|
||||
rerender({ server: { value: 'two' } });
|
||||
|
||||
expect(result.current.draft).toEqual({ value: 'two' });
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves an edit made before the first server response', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{ initialProps: { server: undefined as { value: string } | undefined } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft({ value: 'edited' }));
|
||||
rerender({ server: { value: 'one' } });
|
||||
|
||||
expect(result.current.draft).toEqual({ value: 'edited' });
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a sent draft clean before its refetch arrives', () => {
|
||||
const { result } = renderHook(
|
||||
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
|
||||
{ initialProps: { server: { value: 'one' } } },
|
||||
);
|
||||
|
||||
act(() => result.current.setDraft({ value: 'saved' }));
|
||||
act(() => result.current.markSaved({ value: 'saved' }));
|
||||
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user