/** * Standalone Cam Wall page (#2531). * * The assertions that carry weight are the kiosk ones: a wall on a TV must not * offer controls it cannot honour, must not name the file on the bed, and must * carry its token into the URLs — an MJPEG tag has no Authorization * header, so a missing token means a wall of broken images. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render as rtlRender } from '@testing-library/react'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; import { AuthProvider } from '../../contexts/AuthContext'; import { CamWallPage } from '../../pages/CamWallPage'; import { api, getStreamToken } from '../../api/client'; const KIOSK_TOKEN = 'bblt_abcdefgh_secretsecretsecret'; const FEED = [ { id: 7, name: 'X1C-Lab', camera_rotation: 0, connected: true, state: 'RUNNING', progress: 42, remaining_time: 33, layer_num: 120, total_layers: 300, hms_errors: [], }, ]; // The page renders outside the app layout, so it supplies its own route context. // The shared render() util hard-codes BrowserRouter with no way to seed a query // string, and the query string is the whole point here. function renderAt(search: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, }); return rtlRender( , ); } describe('CamWallPage — kiosk mode', () => { beforeEach(() => { vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED); // AuthProvider probes /auth/me on mount; a kiosk browser has no session. vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 })); localStorage.clear(); }); afterEach(() => { vi.restoreAllMocks(); }); it('fetches the wall with the URL token and renders a tile per printer', async () => { renderAt(`?token=${KIOSK_TOKEN}`); await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument()); expect(api.getCamWallPrinters).toHaveBeenCalledWith(KIOSK_TOKEN); }); it('carries the token into the stream URL', async () => { renderAt(`?token=${KIOSK_TOKEN}`); await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument()); // Tiles start paused (jsdom's IntersectionObserver never reports a tile as // on-screen), and a paused tile renders no at all. So assert on the // module-level token those URLs are built from, which is what CameraTile // reads through withStreamToken(). expect(getStreamToken()).toBe(KIOSK_TOKEN); }); it('offers no settings popover — a TV has nobody standing at it', async () => { renderAt(`?token=${KIOSK_TOKEN}`); await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument()); expect(screen.queryByTitle('Cam wall settings')).not.toBeInTheDocument(); }); it('renders tiles inert — click-through would need a session the token has not got', async () => { renderAt(`?token=${KIOSK_TOKEN}`); await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument()); expect(screen.queryByRole('button', { name: /X1C-Lab/ })).not.toBeInTheDocument(); }); it('refuses to honour ?status=full — a kiosk wall never names the part on the bed', async () => { renderAt(`?token=${KIOSK_TOKEN}&status=full`); await waitFor(() => expect(screen.getByText('X1C-Lab')).toBeInTheDocument()); // 'full' is what adds the progress/layer strip. Capped to 'compact', so the // strip must be absent even though the feed says the printer is at 42%. expect(screen.queryByText('42%')).not.toBeInTheDocument(); expect(screen.queryByText(/Layer 120/)).not.toBeInTheDocument(); }); it('says so plainly when the token has expired or been revoked', async () => { vi.spyOn(api, 'getCamWallPrinters').mockRejectedValue(new Error('401')); renderAt(`?token=${KIOSK_TOKEN}`); await waitFor(() => expect(screen.getByText(/no longer valid/i)).toBeInTheDocument(), ); }); }); describe('CamWallPage — signed out, no token', () => { beforeEach(() => { vi.spyOn(api, 'getCamWallPrinters').mockResolvedValue(FEED); localStorage.clear(); }); afterEach(() => { vi.restoreAllMocks(); }); it('does not reach for the kiosk feed without a token', async () => { vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 401 })); renderAt(''); await act(async () => { await Promise.resolve(); }); // No token means this is an ordinary app page; it must fall back to the // session-authenticated printers API (or bounce to /login), never to the // kiosk endpoint. expect(api.getCamWallPrinters).not.toHaveBeenCalled(); }); });