Files
bambuddy/frontend/src/__tests__/pages/CameraPage.test.tsx
T
maziggy 62950e37c8 fix(camera): restore new-window camera view with auth enabled
Two root causes in the "Camera View Mode = Window" path when auth is on (#979):

  1. PrintersPage opened the popup with `noopener`, which severed the opener
     link and prevented the browser from copying sessionStorage (auth token)
     into the new window. The popup booted unauthenticated, POST
     /printers/camera/stream-token returned 401, and the <img> src went out
     with no ?token=. The backend's RequireCameraStreamTokenIfAuthEnabled
     then rejected every frame with "Valid camera stream token required".
  2. CameraPage computed its stream URL from the module-level stream-token
     cache in withStreamToken(). That cache is populated by a useEffect in
     useStreamTokenSync that runs after render, so even after the token
     resolved the first post-arrival render still produced a tokenless URL
     and nothing triggered another render.

  Fix:
  - Drop `noopener` from the camera popup features (same-origin, trusted).
  - Subscribe CameraPage to the `camera-stream-token` React Query so the
    page re-renders the moment the token arrives.
  - Gate currentUrl on `waitingForStreamToken` and append the token directly
    from the reactive query value instead of the effect-synced module cache.

  Embedded overlay mode was unaffected. Added CameraPage tests covering both
  the auth-enabled (token required, src empty until it arrives, then includes
  ?token=) and auth-disabled (src rendered immediately without token) paths.
2026-04-15 08:06:27 +02:00

191 lines
5.9 KiB
TypeScript

/**
* Tests for the CameraPage component.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
import { CameraPage } from '../../pages/CameraPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
// Mock navigator.sendBeacon which isn't available in jsdom
vi.stubGlobal('navigator', {
...navigator,
sendBeacon: vi.fn().mockReturnValue(true),
});
const mockPrinter = {
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
serial_number: '00M09A350100001',
access_code: '12345678',
model: 'X1C',
enabled: true,
};
// Custom render for CameraPage which needs specific route params
function renderCameraPage(printerId: number) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return rtlRender(
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="/cameras/:printerId" element={<CameraPage />} />
</Routes>
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</MemoryRouter>
</I18nextProvider>
</QueryClientProvider>
);
}
describe('CameraPage', () => {
const originalTitle = document.title;
beforeEach(() => {
server.use(
http.get('/api/v1/printers/:id', () => {
return HttpResponse.json(mockPrinter);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({
connected: true,
state: 'IDLE',
progress: 0,
});
}),
http.post('/api/v1/printers/:id/camera/stop', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/v1/printers/:id/camera/status', () => {
return HttpResponse.json({ active: true, stalled: false });
})
);
});
afterEach(() => {
document.title = originalTitle;
});
describe('rendering', () => {
it('renders camera page for printer', async () => {
renderCameraPage(1);
// Camera page should load - look for the header with camera icon
await waitFor(() => {
expect(screen.getByRole('heading')).toBeInTheDocument();
});
});
it('shows live and snapshot mode buttons', async () => {
renderCameraPage(1);
await waitFor(() => {
// Check for translation key or translated text
expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument();
expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument();
});
});
it('shows printer name in header', async () => {
renderCameraPage(1);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
});
});
describe('camera controls', () => {
it('renders without crashing', async () => {
renderCameraPage(1);
// Just verify no crash during render
await waitFor(() => {
expect(document.body).toBeInTheDocument();
});
});
});
describe('stream token handling (#979)', () => {
it('does not render image src until stream token arrives when auth is enabled', async () => {
let resolveToken!: (value: unknown) => void;
const tokenPromise = new Promise((resolve) => {
resolveToken = resolve;
});
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false })
),
http.post('*/api/v1/printers/camera/stream-token', async () => {
await tokenPromise;
return HttpResponse.json({ token: 'tok-abc' });
})
);
renderCameraPage(1);
// Before the token resolves the <img> should not have a src pointing at
// the stream endpoint — otherwise the backend would 401 with the
// "Valid camera stream token required" error from #979.
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const img = document.querySelector('img') as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img?.getAttribute('src') || '').not.toContain('/camera/stream');
resolveToken(undefined);
// After the token resolves the image src picks it up as ?token=...
await waitFor(() => {
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
expect(src).toContain('/camera/stream');
expect(src).toContain('token=tok-abc');
});
});
it('renders image src immediately when auth is disabled (no token required)', async () => {
renderCameraPage(1);
await waitFor(() => {
const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';
expect(src).toContain(`/api/v1/printers/1/camera/stream`);
expect(src).not.toContain('token=');
});
});
});
describe('invalid printer', () => {
it('shows invalid printer message for ID 0', async () => {
renderCameraPage(0);
await waitFor(() => {
// Check for translation key or translated text
expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
});
});
});
});