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 }) => (
{value}
), })); 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 & { happLinkEnable?: boolean; }; interface SubjectProps { open: boolean; client: ClientRecord | null; subSettings: TestSubSettings; onOpenChange: (open: boolean) => void; } interface SubjectHandle { update: (patch: Partial) => void; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } function success(encryptedLink = HAPP_LINK) { return new Msg(true, '', { encryptedLink }); } const Subject = forwardRef }>(function Subject( { overrides }, ref, ) { const [props, setProps] = useState({ open: true, client: CLIENT, subSettings: SUB_SETTINGS, onOpenChange: vi.fn(), ...overrides, }); useImperativeHandle( ref, () => ({ update: (patch) => setProps((current) => ({ ...current, ...patch })), }), [], ); return ( ); }); function LocationProbe() { const location = useLocation(); return ( {location.pathname} {location.search} {location.hash} ); } function renderSubject(overrides: Partial = {}) { const subjectRef = createRef(); const onOpenChange = vi.fn(); const view = renderWithProviders( , ); return { ...view, onOpenChange, update(patch: Partial) { 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>(); 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>(); const requestB = deferred>(); 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>(); 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) => selectVariant('Standard')], ['closing', (view: ReturnType) => view.update({ open: false })], [ 'changing client id', (view: ReturnType) => view.update({ client: { ...CLIENT, id: 77 } }), ], [ 'changing subId', (view: ReturnType) => view.update({ client: { ...CLIENT, subId: 'beta' } }), ], [ 'changing the effective subscription source', (view: ReturnType) => view.update({ subSettings: { ...SUB_SETTINGS, subURI: 'https://other.example/sub/' }, }), ], ])('ignores a generation response after %s', async (_name, retire) => { const oldRequest = deferred>(); const nextRequest = deferred>(); 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(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(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(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(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(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>(); 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(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(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>(); 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(); }); });