mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-13 09:25:14 +03:00
8f47b53879
Seven tabs, several holding one or two settings, made the page hard to scan. The encrypted-links switch moves above the tabs under auto-detection, the colour profile joins the banners under Appearance & Theme, and Android per-app proxy joins Network & TUN Engine. The QR modal's settings link no longer needs a happTab selector, and the three orphaned tab-label keys are dropped from every locale.
658 lines
24 KiB
TypeScript
658 lines
24 KiB
TypeScript
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createRef, forwardRef, useImperativeHandle, useState } from 'react';
|
|
import { MemoryRouter, useLocation } from 'react-router';
|
|
|
|
import type { HappLinkResult } from '@/generated/types';
|
|
import type { ClientRecord } from '@/hooks/useClients';
|
|
import ClientQrModal from '@/pages/clients/ClientQrModal';
|
|
import { HttpUtil, Msg } from '@/utils';
|
|
import { renderWithProviders } from './test-utils';
|
|
|
|
vi.mock('@/pages/inbounds/qr', () => ({
|
|
QrPanel: ({ value, showQr = true }: { value: string; showQr?: boolean }) => (
|
|
<div data-testid="qr-panel-value" data-show-qr={String(showQr)}>
|
|
{value}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
const STANDARD_LINK = 'https://panel.example/sub/alpha';
|
|
const HAPP_LINK = 'happ://crypt5/encrypted-alpha';
|
|
const HAPP_OPTION_LABEL = 'Happ Encrypted Link';
|
|
const SOURCE_TOO_LONG_HINT =
|
|
'The subscription URL exceeds the panel limit of 8192 UTF-8 bytes. Shorten the subscription URL or use Standard.';
|
|
const CLIENT: ClientRecord = { id: 42, email: 'alice@example.com', subId: 'alpha' };
|
|
const SUB_SETTINGS = {
|
|
enable: true,
|
|
subURI: 'https://panel.example/sub/',
|
|
subJsonURI: '',
|
|
subJsonEnable: false,
|
|
happLinkEnable: true,
|
|
};
|
|
|
|
type TestSubSettings = Omit<typeof SUB_SETTINGS, 'happLinkEnable'> & {
|
|
happLinkEnable?: boolean;
|
|
};
|
|
|
|
interface SubjectProps {
|
|
open: boolean;
|
|
client: ClientRecord | null;
|
|
subSettings: TestSubSettings;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface SubjectHandle {
|
|
update: (patch: Partial<SubjectProps>) => void;
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((next) => {
|
|
resolve = next;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function success(encryptedLink = HAPP_LINK) {
|
|
return new Msg<HappLinkResult>(true, '', { encryptedLink });
|
|
}
|
|
|
|
const Subject = forwardRef<SubjectHandle, { overrides: Partial<SubjectProps> }>(function Subject(
|
|
{ overrides },
|
|
ref,
|
|
) {
|
|
const [props, setProps] = useState<SubjectProps>({
|
|
open: true,
|
|
client: CLIENT,
|
|
subSettings: SUB_SETTINGS,
|
|
onOpenChange: vi.fn(),
|
|
...overrides,
|
|
});
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
update: (patch) => setProps((current) => ({ ...current, ...patch })),
|
|
}),
|
|
[],
|
|
);
|
|
return (
|
|
<ClientQrModal
|
|
open={props.open}
|
|
client={props.client}
|
|
inboundsById={{}}
|
|
subSettings={props.subSettings}
|
|
onOpenChange={props.onOpenChange}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return (
|
|
<output data-testid="location">
|
|
{location.pathname}
|
|
{location.search}
|
|
{location.hash}
|
|
</output>
|
|
);
|
|
}
|
|
|
|
function renderSubject(overrides: Partial<SubjectProps> = {}) {
|
|
const subjectRef = createRef<SubjectHandle>();
|
|
const onOpenChange = vi.fn();
|
|
const view = renderWithProviders(
|
|
<MemoryRouter initialEntries={['/clients']}>
|
|
<Subject ref={subjectRef} overrides={{ onOpenChange, ...overrides }} />
|
|
<LocationProbe />
|
|
</MemoryRouter>,
|
|
);
|
|
return {
|
|
...view,
|
|
onOpenChange,
|
|
update(patch: Partial<SubjectProps>) {
|
|
act(() => subjectRef.current?.update(patch));
|
|
},
|
|
};
|
|
}
|
|
|
|
// Opening fetches sub links with no visible loading state; settle it inside act().
|
|
async function settleSubLinks() {
|
|
await act(async () => {});
|
|
}
|
|
|
|
function selectVariant(name: 'Standard' | 'Happ') {
|
|
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
|
|
}
|
|
|
|
function actionButton(name: 'Retry' | 'Regenerate') {
|
|
return screen.getByRole('button', { name: new RegExp(name) }) as HTMLButtonElement;
|
|
}
|
|
|
|
describe('ClientQrModal Happ presentation', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(HttpUtil.post).mockReset();
|
|
});
|
|
|
|
it('opens on Standard without generating a Happ link', async () => {
|
|
renderSubject();
|
|
await settleSubLinks();
|
|
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('names the Happ option as an encrypted link', async () => {
|
|
renderSubject();
|
|
await settleSubLinks();
|
|
|
|
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
|
|
});
|
|
|
|
it.each([
|
|
['missing', undefined],
|
|
['false', false],
|
|
])('marks the selectable Happ option as locked when the gate is %s', async (_name, gate) => {
|
|
const subSettings: TestSubSettings = {
|
|
enable: SUB_SETTINGS.enable,
|
|
subURI: SUB_SETTINGS.subURI,
|
|
subJsonURI: SUB_SETTINGS.subJsonURI,
|
|
subJsonEnable: SUB_SETTINGS.subJsonEnable,
|
|
};
|
|
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
|
|
|
renderSubject({ subSettings });
|
|
await settleSubLinks();
|
|
|
|
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
|
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
|
expect(standard.checked).toBe(true);
|
|
expect(happ.disabled).toBe(false);
|
|
expect(
|
|
screen.getByLabelText('Enable Happ link generation in Settings before using Happ.', {
|
|
selector: '.anticon-lock',
|
|
}),
|
|
).toBeTruthy();
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['missing', undefined],
|
|
['false', false],
|
|
])(
|
|
'replaces the blank Happ content with a persistent empty state when the gate is %s',
|
|
async (_name, gate) => {
|
|
const subSettings: TestSubSettings = {
|
|
enable: SUB_SETTINGS.enable,
|
|
subURI: SUB_SETTINGS.subURI,
|
|
subJsonURI: SUB_SETTINGS.subJsonURI,
|
|
subJsonEnable: SUB_SETTINGS.subJsonEnable,
|
|
};
|
|
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
|
|
|
renderSubject({ subSettings });
|
|
await settleSubLinks();
|
|
|
|
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
|
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
|
fireEvent.click(happ);
|
|
|
|
expect(standard.checked).toBe(false);
|
|
expect(happ.checked).toBe(true);
|
|
expect(screen.getByText('Happ encrypted link generation is not enabled')).toBeTruthy();
|
|
expect(
|
|
screen.getByText(
|
|
'Enable local generation of encrypted Happ subscription links. (Only for Happ)',
|
|
),
|
|
).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: 'Go to Settings' })).toBeTruthy();
|
|
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('removes the hover and focus tooltip from the locked Happ option', async () => {
|
|
renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ });
|
|
const happLabel = happ.closest('label');
|
|
expect(happLabel).not.toBeNull();
|
|
|
|
fireEvent.mouseEnter(happLabel!);
|
|
fireEvent.focus(happ);
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
});
|
|
|
|
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
});
|
|
|
|
it('closes the QR modal and deep-links to Happ settings without generating', async () => {
|
|
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
await settleSubLinks();
|
|
selectVariant('Happ');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
|
|
|
expect(view.onOpenChange).toHaveBeenCalledOnce();
|
|
expect(view.onOpenChange).toHaveBeenCalledWith(false);
|
|
expect(screen.getByTestId('location').textContent).toBe(
|
|
'/settings?subscriptionTab=happ#subscription',
|
|
);
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns to Standard without auto-generating when the gate is enabled after selecting Happ', async () => {
|
|
const view = renderSubject({
|
|
subSettings: { ...SUB_SETTINGS, happLinkEnable: false },
|
|
});
|
|
selectVariant('Happ');
|
|
expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).checked).toBe(true);
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
|
|
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: true } });
|
|
|
|
await waitFor(() =>
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
),
|
|
);
|
|
expect(
|
|
screen.queryByText(
|
|
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
),
|
|
).toBeNull();
|
|
expect(HttpUtil.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the local encryption notice out of Standard and shows it only in Happ', async () => {
|
|
vi.mocked(HttpUtil.post).mockReturnValue(new Promise(() => {}));
|
|
renderSubject();
|
|
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
screen.queryByText(
|
|
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
),
|
|
).toBeNull();
|
|
|
|
selectVariant('Happ');
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
|
|
),
|
|
).toBeTruthy();
|
|
expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('posts once with no body and silent errors when Standard switches to Happ', async () => {
|
|
const request = deferred<Msg<HappLinkResult>>();
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
|
|
renderSubject();
|
|
const dialogCount = screen.queryAllByRole('dialog').length;
|
|
|
|
selectVariant('Happ');
|
|
|
|
await waitFor(() => {
|
|
expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
expect(HttpUtil.post).toHaveBeenCalledWith('/panel/api/clients/happLink/42', undefined, {
|
|
silent: true,
|
|
});
|
|
});
|
|
expect(confirmSpy).not.toHaveBeenCalled();
|
|
expect(screen.queryAllByRole('dialog')).toHaveLength(dialogCount);
|
|
confirmSpy.mockRestore();
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
|
|
false,
|
|
);
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
});
|
|
|
|
it('removes the Happ value on leave and makes a fresh request on re-entry', async () => {
|
|
vi.mocked(HttpUtil.post)
|
|
.mockResolvedValueOnce(success())
|
|
.mockResolvedValueOnce(success('happ://crypt5/encrypted-second'));
|
|
renderSubject();
|
|
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
selectVariant('Standard');
|
|
expect(screen.queryByText(HAPP_LINK)).toBeNull();
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText('happ://crypt5/encrypted-second')).toBeTruthy();
|
|
expect(HttpUtil.post).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps request B current when request A resolves after leaving and re-entering Happ', async () => {
|
|
const requestA = deferred<Msg<HappLinkResult>>();
|
|
const requestB = deferred<Msg<HappLinkResult>>();
|
|
vi.mocked(HttpUtil.post)
|
|
.mockReturnValueOnce(requestA.promise)
|
|
.mockReturnValueOnce(requestB.promise);
|
|
renderSubject();
|
|
|
|
selectVariant('Happ');
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
selectVariant('Standard');
|
|
selectVariant('Happ');
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
|
|
|
await act(async () => {
|
|
requestA.resolve(success('happ://crypt5/request-a'));
|
|
await requestA.promise;
|
|
});
|
|
expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
|
|
await act(async () => {
|
|
requestB.resolve(success('happ://crypt5/request-b'));
|
|
await requestB.promise;
|
|
});
|
|
expect(await screen.findByText('happ://crypt5/request-b')).toBeTruthy();
|
|
expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
|
|
});
|
|
|
|
it('returns to Standard and ignores an in-flight response when the gate turns off', async () => {
|
|
const request = deferred<Msg<HappLinkResult>>();
|
|
vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
|
|
const view = renderSubject();
|
|
selectVariant('Happ');
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
|
|
await waitFor(() => {
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).disabled).toBe(
|
|
false,
|
|
);
|
|
});
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
request.resolve(success('happ://crypt5/retired-by-gate'));
|
|
await request.promise;
|
|
});
|
|
expect(screen.queryByText('happ://crypt5/retired-by-gate')).toBeNull();
|
|
expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('keeps the dialog mounted through close and shows loading instead of noLinks on reopen', async () => {
|
|
const get = vi.mocked(HttpUtil.get);
|
|
const previousGet = get.getMockImplementation();
|
|
get.mockReturnValue(new Promise(() => {}));
|
|
try {
|
|
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, enable: false } });
|
|
const dialog = screen.getByRole('dialog');
|
|
|
|
view.update({ open: false });
|
|
expect(document.body.contains(dialog)).toBe(true);
|
|
|
|
view.update({ open: true });
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).not.toBeNull(),
|
|
);
|
|
expect(screen.queryByText(/No shareable links/)).toBeNull();
|
|
} finally {
|
|
get.mockImplementation(previousGet!);
|
|
}
|
|
});
|
|
|
|
it('resets to Standard across close and reopen without reusing a prior Happ value', async () => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(success());
|
|
const view = renderSubject();
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
view.update({ open: false });
|
|
view.update({ open: true });
|
|
await settleSubLinks();
|
|
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
expect(screen.queryByText(HAPP_LINK)).toBeNull();
|
|
expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([
|
|
['leaving Happ', (_view: ReturnType<typeof renderSubject>) => selectVariant('Standard')],
|
|
['closing', (view: ReturnType<typeof renderSubject>) => view.update({ open: false })],
|
|
[
|
|
'changing client id',
|
|
(view: ReturnType<typeof renderSubject>) => view.update({ client: { ...CLIENT, id: 77 } }),
|
|
],
|
|
[
|
|
'changing subId',
|
|
(view: ReturnType<typeof renderSubject>) =>
|
|
view.update({ client: { ...CLIENT, subId: 'beta' } }),
|
|
],
|
|
[
|
|
'changing the effective subscription source',
|
|
(view: ReturnType<typeof renderSubject>) =>
|
|
view.update({
|
|
subSettings: { ...SUB_SETTINGS, subURI: 'https://other.example/sub/' },
|
|
}),
|
|
],
|
|
])('ignores a generation response after %s', async (_name, retire) => {
|
|
const oldRequest = deferred<Msg<HappLinkResult>>();
|
|
const nextRequest = deferred<Msg<HappLinkResult>>();
|
|
vi.mocked(HttpUtil.post)
|
|
.mockReturnValueOnce(oldRequest.promise)
|
|
.mockReturnValue(nextRequest.promise);
|
|
const view = renderSubject();
|
|
selectVariant('Happ');
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
|
|
retire(view);
|
|
await act(async () => {
|
|
oldRequest.resolve(success('happ://crypt5/retired-response'));
|
|
await oldRequest.promise;
|
|
});
|
|
|
|
expect(screen.queryByText('happ://crypt5/retired-response')).toBeNull();
|
|
});
|
|
|
|
it('shows only the localized generic hint and Retry after a backend failure', async () => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(
|
|
new Msg<HappLinkResult>(false, 'provider token leaked by backend', null),
|
|
);
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
await screen.findByText('Retry');
|
|
expect(actionButton('Retry').disabled).toBe(false);
|
|
expect(
|
|
screen.getByText(
|
|
'The Happ link could not be generated. Retry, or check Overview -> Logs for details.',
|
|
),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText(/provider token leaked/i)).toBeNull();
|
|
});
|
|
|
|
it('retries with a fresh request and exposes Regenerate after success', async () => {
|
|
vi.mocked(HttpUtil.post)
|
|
.mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'backend detail', null))
|
|
.mockResolvedValueOnce(success());
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
await screen.findByText('Retry');
|
|
fireEvent.click(actionButton('Retry'));
|
|
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
expect(actionButton('Regenerate').disabled).toBe(false);
|
|
expect(HttpUtil.post).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('explains a source length failure without Retry and keeps Standard available', async () => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(
|
|
new Msg<HappLinkResult>(false, 'happ_source_too_long', null),
|
|
);
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
expect(screen.queryByText('happ_source_too_long')).toBeNull();
|
|
|
|
selectVariant('Standard');
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
|
|
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
expect(HttpUtil.post).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([
|
|
['non-exact error code', false, 'happ_source_too_long token=secret'],
|
|
['successful malformed response', true, 'happ_source_too_long'],
|
|
])('does not trust a %s as a source length failure', async (_name, successful, message) => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(new Msg<HappLinkResult>(successful, message, null));
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
expect(await screen.findByRole('button', { name: 'Retry' })).toBeTruthy();
|
|
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
expect(screen.queryByText(message)).toBeNull();
|
|
});
|
|
|
|
it('clears a source length failure when the subscription source changes', async () => {
|
|
vi.mocked(HttpUtil.post)
|
|
.mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'happ_source_too_long', null))
|
|
.mockResolvedValueOnce(success());
|
|
const view = renderSubject();
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
|
|
|
|
view.update({ subSettings: { ...SUB_SETTINGS, subURI: 'https://short.example/sub/' } });
|
|
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
|
true,
|
|
);
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
});
|
|
|
|
it('ignores a source length failure from a retired request', async () => {
|
|
const retired = deferred<Msg<HappLinkResult>>();
|
|
vi.mocked(HttpUtil.post).mockReturnValueOnce(retired.promise).mockResolvedValueOnce(success());
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
selectVariant('Standard');
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
retired.resolve(new Msg<HappLinkResult>(false, 'happ_source_too_long', null));
|
|
await retired.promise;
|
|
});
|
|
expect(screen.getByTestId('qr-panel-value').textContent).toBe(HAPP_LINK);
|
|
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['Retry', new Msg<HappLinkResult>(false, 'backend detail', null)],
|
|
['Regenerate', success()],
|
|
])('does not let a stale %s action bypass a disabled gate', async (action, response) => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(response);
|
|
const view = renderSubject();
|
|
selectVariant('Happ');
|
|
const staleAction = await screen.findByRole('button', { name: new RegExp(action) });
|
|
|
|
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('button', { name: new RegExp(action) })).toBeNull(),
|
|
);
|
|
fireEvent.click(staleAction);
|
|
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
|
|
});
|
|
|
|
it('clears the old QR and hides duplicate regeneration while loading', async () => {
|
|
const regeneration = deferred<Msg<HappLinkResult>>();
|
|
vi.mocked(HttpUtil.post)
|
|
.mockResolvedValueOnce(success())
|
|
.mockReturnValueOnce(regeneration.promise);
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
|
|
await waitFor(() => expect(actionButton('Regenerate').disabled).toBe(false));
|
|
|
|
fireEvent.click(actionButton('Regenerate'));
|
|
|
|
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
|
|
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
|
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['ordinary', 'happ://crypt5/AaBbCc-._~'],
|
|
['standard Base64', 'happ://crypt5/AaBb+Cc/Dd=='],
|
|
['maximum-size QR', `happ://crypt5/${'a'.repeat(2939)}`],
|
|
])('passes a valid %s encryptedLink unchanged to QrPanel', async (_name, exactLink) => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(success(exactLink));
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
const panel = await screen.findByTestId('qr-panel-value');
|
|
expect(panel.textContent).toBe(exactLink);
|
|
expect(panel.getAttribute('data-show-qr')).toBe('true');
|
|
});
|
|
|
|
it.each([
|
|
['ASCII', `happ://crypt5/${'a'.repeat(2940)}`],
|
|
['multi-byte', `happ://crypt5/${'界'.repeat(1000)}`],
|
|
])('keeps a valid %s link available when it is too large for a QR code', async (_name, link) => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(success(link));
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
const panel = await screen.findByTestId('qr-panel-value');
|
|
expect(panel.textContent).toBe(link);
|
|
expect(panel.getAttribute('data-show-qr')).toBe('false');
|
|
expect(
|
|
screen.getByText(
|
|
'This Happ link is valid, but it is too long to display as a QR code. Use Copy to use the complete link.',
|
|
),
|
|
).toBeTruthy();
|
|
expect(actionButton('Regenerate').disabled).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['non-string', { encryptedLink: 7 }],
|
|
['stale crypt4 format', { encryptedLink: 'happ://crypt4/old-format' }],
|
|
['empty payload', { encryptedLink: 'happ://crypt5/' }],
|
|
['wrong scheme', { encryptedLink: 'https://provider.example/link' }],
|
|
['whitespace', { encryptedLink: 'happ://crypt5/has space' }],
|
|
['control character', { encryptedLink: 'happ://crypt5/example\n' }],
|
|
])('rejects a %s encryptedLink before rendering QrPanel', async (_name, obj) => {
|
|
vi.mocked(HttpUtil.post).mockResolvedValue(new Msg(true, '', obj));
|
|
renderSubject();
|
|
selectVariant('Happ');
|
|
|
|
await screen.findByText('Retry');
|
|
expect(actionButton('Retry').disabled).toBe(false);
|
|
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
|
|
});
|
|
});
|