mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 07:40:59 +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,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
@@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate';
|
|||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||||
|
|
||||||
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
|
||||||
|
type SettingSaveResult = {
|
||||||
|
msg: Msg<unknown>;
|
||||||
|
saved?: AllSetting;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
@@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
|||||||
|
|
||||||
export function useAllSettings() {
|
export function useAllSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<AllSetting>(() => new AllSetting());
|
|
||||||
const [extraSpinning, setExtraSpinning] = useState(false);
|
const [extraSpinning, setExtraSpinning] = useState(false);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -28,41 +32,54 @@ export function useAllSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
const server = useMemo(() => new AllSetting(query.data), [query.data]);
|
||||||
|
const { draft, setDraft, isDirty, markSaved } = useServerDraft(
|
||||||
useEffect(() => {
|
query.data === undefined ? undefined : server,
|
||||||
if (query.data !== undefined) {
|
(setting) => new AllSetting(setting),
|
||||||
setDraft(new AllSetting(query.data));
|
(left, right) => left.equals(right),
|
||||||
}
|
);
|
||||||
}, [query.data]);
|
const allSetting = draft ?? server;
|
||||||
|
|
||||||
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const next = new AllSetting(prev);
|
const next = new AllSetting(prev ?? server);
|
||||||
Object.assign(next, patch);
|
Object.assign(next, patch);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [server, setDraft]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
|
mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise<SettingSaveResult> => {
|
||||||
const payload = { ...next };
|
const next = { ...payload };
|
||||||
const body = AllSettingSchema.partial().safeParse(payload);
|
const body = AllSettingSchema.partial().safeParse(next);
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
}
|
}
|
||||||
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
|
const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next);
|
||||||
|
return { msg, saved };
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: ({ msg, saved }) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (!msg?.success) return;
|
||||||
|
if (saved) markSaved(saved);
|
||||||
|
queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
|
const saveAll = useCallback(async () => {
|
||||||
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
|
const saved = new AllSetting(allSetting);
|
||||||
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
|
return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg;
|
||||||
|
}, [allSetting, saveMut]);
|
||||||
|
const savePayload = useCallback(
|
||||||
|
async (payload: SettingSavePayload) => {
|
||||||
|
const saved = new AllSetting(allSetting);
|
||||||
|
Object.assign(saved, payload);
|
||||||
|
return (await saveMut.mutateAsync({ payload, saved })).msg;
|
||||||
|
},
|
||||||
|
[allSetting, saveMut],
|
||||||
|
);
|
||||||
|
const saveDisabled = !isDirty;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allSetting: draft,
|
allSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
fetched: query.data !== undefined,
|
fetched: query.data !== undefined,
|
||||||
spinning: extraSpinning || saveMut.isPending,
|
spinning: extraSpinning || saveMut.isPending,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useServerDraft<T>(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) {
|
||||||
|
const cloneRef = useRef(clone);
|
||||||
|
const equalsRef = useRef(equals);
|
||||||
|
cloneRef.current = clone;
|
||||||
|
equalsRef.current = equals;
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<T | undefined>();
|
||||||
|
const [baseline, setBaseline] = useState<T | undefined>();
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
const baselineRef = useRef(baseline);
|
||||||
|
draftRef.current = draft;
|
||||||
|
baselineRef.current = baseline;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (server === undefined) return;
|
||||||
|
const currentDraft = draftRef.current;
|
||||||
|
const currentBaseline = baselineRef.current;
|
||||||
|
const isDirty = currentDraft !== undefined
|
||||||
|
&& (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
|
||||||
|
setBaseline(server);
|
||||||
|
if (isDirty && !equalsRef.current(currentDraft, server)) return;
|
||||||
|
setDraft(cloneRef.current(server));
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const markSaved = useCallback((value: T) => {
|
||||||
|
setBaseline(cloneRef.current(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
|
||||||
|
[baseline, draft],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { draft, setDraft, isDirty, markSaved };
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type OutboundTrafficRow,
|
type OutboundTrafficRow,
|
||||||
} from '@/schemas/xray';
|
} from '@/schemas/xray';
|
||||||
|
|
||||||
const DIRTY_POLL_MS = 1000;
|
|
||||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||||
// One HTTP-mode batch request tests this many outbounds through a single
|
// One HTTP-mode batch request tests this many outbounds through a single
|
||||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||||
@@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
|||||||
// results progressively.
|
// results progressively.
|
||||||
const HTTP_BATCH_CHUNK = 16;
|
const HTTP_BATCH_CHUNK = 16;
|
||||||
|
|
||||||
|
function normalizeOutboundTestUrl(url: string) {
|
||||||
|
return url || DEFAULT_TEST_URL;
|
||||||
|
}
|
||||||
|
|
||||||
export function isUdpOutbound(outbound: unknown): boolean {
|
export function isUdpOutbound(outbound: unknown): boolean {
|
||||||
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
|
||||||
const p = o?.protocol;
|
const p = o?.protocol;
|
||||||
@@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [saveDisabled, setSaveDisabled] = useState(true);
|
|
||||||
const [xraySetting, setXraySettingState] = useState('');
|
const [xraySetting, setXraySettingState] = useState('');
|
||||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||||
|
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||||
|
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||||
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||||
@@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||||
const [testingAll, setTestingAll] = useState(false);
|
const [testingAll, setTestingAll] = useState(false);
|
||||||
|
|
||||||
const oldXraySettingRef = useRef('');
|
|
||||||
const oldOutboundTestUrlRef = useRef('');
|
|
||||||
const syncingRef = useRef(false);
|
const syncingRef = useRef(false);
|
||||||
const xraySettingRef = useRef('');
|
const xraySettingRef = useRef('');
|
||||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||||
|
const savedXraySettingRef = useRef(savedXraySetting);
|
||||||
|
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||||
|
|
||||||
xraySettingRef.current = xraySetting;
|
xraySettingRef.current = xraySetting;
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
outboundTestUrlRef.current = outboundTestUrl;
|
||||||
|
savedXraySettingRef.current = savedXraySetting;
|
||||||
|
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||||
templateSettingsRef.current = templateSettings;
|
templateSettingsRef.current = templateSettings;
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||||
|
|
||||||
// Seed local editor state from the config query. Runs on first fetch and
|
|
||||||
// every time the query refetches (e.g. after a successful save).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!configQuery.data) return;
|
if (!configQuery.data) return;
|
||||||
const obj = configQuery.data;
|
const obj = configQuery.data;
|
||||||
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
const pretty = JSON.stringify(obj.xraySetting, null, 2);
|
||||||
syncingRef.current = true;
|
const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
|
||||||
setXraySettingState(pretty);
|
|
||||||
setTemplateSettingsState(obj.xraySetting);
|
|
||||||
oldXraySettingRef.current = pretty;
|
|
||||||
syncingRef.current = false;
|
|
||||||
setInboundTags(obj.inboundTags || []);
|
setInboundTags(obj.inboundTags || []);
|
||||||
setClientReverseTags(obj.clientReverseTags || []);
|
setClientReverseTags(obj.clientReverseTags || []);
|
||||||
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||||
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
const isDirty = savedXraySettingRef.current !== xraySettingRef.current
|
||||||
|
|| savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
|
if (isDirty) return;
|
||||||
|
syncingRef.current = true;
|
||||||
|
setXraySettingState(pretty);
|
||||||
|
setTemplateSettingsState(obj.xraySetting);
|
||||||
|
setSavedXraySetting(pretty);
|
||||||
|
syncingRef.current = false;
|
||||||
setOutboundTestUrlState(nextUrl);
|
setOutboundTestUrlState(nextUrl);
|
||||||
oldOutboundTestUrlRef.current = nextUrl;
|
setSavedOutboundTestUrl(nextUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
}, [configQuery.data]);
|
}, [configQuery.data]);
|
||||||
|
|
||||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
const fetched = configQuery.data !== undefined || configQuery.isError;
|
||||||
@@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sentXraySetting = xraySettingRef.current;
|
const sentXraySetting = xraySettingRef.current;
|
||||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||||
xraySetting: sentXraySetting,
|
xraySetting: sentXraySetting,
|
||||||
outboundTestUrl: sentTestUrl,
|
outboundTestUrl: sentTestUrl,
|
||||||
@@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
},
|
},
|
||||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
oldXraySettingRef.current = sentXraySetting;
|
setSavedXraySetting(sentXraySetting);
|
||||||
oldOutboundTestUrlRef.current = sentTestUrl;
|
setSavedOutboundTestUrl(sentTestUrl);
|
||||||
setSaveDisabled(true);
|
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
}
|
}
|
||||||
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
const saveDisabled = savedXraySetting === xraySetting
|
||||||
const timer = window.setInterval(() => {
|
&& savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||||
const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
|
|
||||||
const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
|
|
||||||
setSaveDisabled(!(dirtyXray || dirtyUrl));
|
|
||||||
}, DIRTY_POLL_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
|
import { keys } from '@/api/queryKeys';
|
||||||
import { makeTestQueryClient } from '@/test/test-utils';
|
import { makeTestQueryClient } from '@/test/test-utils';
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
|
||||||
@@ -25,4 +26,80 @@ describe('useAllSettings', () => {
|
|||||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||||
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
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