mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
191 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|
|
});
|