Skip to content

Commit c1b5230

Browse files
committed
frontend: add component tests for the navbar
1 parent e99c923 commit c1b5230

File tree

2 files changed

+166
-23
lines changed

2 files changed

+166
-23
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { render, screen, fireEvent } from '@testing-library/preact';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import * as NavbarModule from './Navbar';
4+
import { connected as connectedSignal } from './Navbar';
5+
import { useLocation } from 'preact-iso';
6+
import { fetchClient, AppState, loggedIn, bc, resetSecret, clearSecretKeyFromServiceWorker } from '../utils';
7+
8+
// Use actual module for component and functions (test-setup re-exports actual)
9+
const { CustomNavbar } = NavbarModule;
10+
11+
vi.mock('median-js-bridge', () => ({
12+
default: {
13+
isNativeApp: () => false,
14+
sidebar: { setItems: vi.fn() },
15+
},
16+
}));
17+
18+
vi.mock('logo', () => ({ default: 'logo.png' }));
19+
20+
describe('Navbar', () => {
21+
beforeEach(() => {
22+
vi.restoreAllMocks();
23+
// Reset connection state
24+
connectedSignal.value = false;
25+
// Reset location
26+
const loc = useLocation() as unknown as { url: string };
27+
loc.url = '/';
28+
});
29+
30+
it('renders links and toggles collapse', async () => {
31+
render(<CustomNavbar />);
32+
33+
const navbar = screen.getByRole('navigation');
34+
expect(navbar).toBeInTheDocument();
35+
36+
expect(screen.getByText('chargers')).toBeInTheDocument();
37+
expect(screen.getByText('token')).toBeInTheDocument();
38+
expect(screen.getByText('user')).toBeInTheDocument();
39+
expect(screen.getByText('logout')).toBeInTheDocument();
40+
41+
const toggler = screen.getByRole('button');
42+
fireEvent.click(toggler);
43+
fireEvent.click(toggler);
44+
});
45+
46+
it('hides when connected signal is true', async () => {
47+
connectedSignal.value = true;
48+
render(<CustomNavbar />);
49+
const navbar = screen.getByRole('navigation', { hidden: true });
50+
expect(navbar).toHaveAttribute('hidden');
51+
});
52+
53+
it('logout() clears state and calls API (single session)', async () => {
54+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockReset();
55+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ error: undefined });
56+
57+
const actual = await vi.importActual<typeof import('./Navbar')>('./Navbar');
58+
await actual.logout(false);
59+
60+
// Assert
61+
expect(fetchClient.GET).toHaveBeenCalledWith('/user/logout', { params: { query: { logout_all: false } }, credentials: 'same-origin' });
62+
expect(resetSecret).toHaveBeenCalled();
63+
expect(window.localStorage.removeItem).toHaveBeenCalledWith('loginSalt');
64+
expect(clearSecretKeyFromServiceWorker).toHaveBeenCalled();
65+
expect(loggedIn.value).toBe(AppState.LoggedOut);
66+
expect(bc.postMessage).toHaveBeenCalledWith('logout');
67+
});
68+
69+
it('logout() shows alert when logout_all fails', async () => {
70+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockReset();
71+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ error: 'boom' });
72+
const { showAlert } = await vi.importMock<typeof import('./Alert')>('./Alert');
73+
const showAlertSpy = showAlert as unknown as ReturnType<typeof vi.fn>;
74+
75+
const actual = await vi.importActual<typeof import('./Navbar')>('./Navbar');
76+
await actual.logout(true);
77+
78+
// Assert
79+
expect(showAlertSpy).toHaveBeenCalledWith('boom', 'danger');
80+
expect(resetSecret).not.toHaveBeenCalled();
81+
});
82+
83+
it('setAppNavigation configures sidebar via Median', async () => {
84+
const Median = await vi.importMock<typeof import('median-js-bridge')>('median-js-bridge');
85+
const setItems = vi.spyOn(Median.default.sidebar, 'setItems');
86+
const mod = await vi.importActual<typeof import('./Navbar')>('./Navbar');
87+
mod.setAppNavigation();
88+
expect(setItems).toHaveBeenCalled();
89+
});
90+
91+
it('clicking logout link triggers logout(false)', async () => {
92+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockReset();
93+
(fetchClient.GET as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ error: undefined });
94+
render(<CustomNavbar />);
95+
96+
const logoutLink = screen.getByText('logout');
97+
fireEvent.click(logoutLink);
98+
99+
// Assert that the API was called with logout_all=false
100+
await Promise.resolve();
101+
expect(fetchClient.GET).toHaveBeenCalledWith('/user/logout', { params: { query: { logout_all: false } }, credentials: 'same-origin' });
102+
});
103+
});

frontend/src/test-setup.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -178,23 +178,23 @@ vi.mock('react-bootstrap', () => {
178178
Item: ({ children, ...props }: MockComponentProps) => h('button', { ...props }, children),
179179
};
180180

181-
const Nav = ({ children, ...props }: MockComponentProps) =>
182-
h('nav', { ...props }, children);
181+
const Nav = ({ children, className }: MockComponentProps) =>
182+
h('div', { className }, children);
183183

184-
Nav.Link = ({ children, ...props }: MockComponentProps) =>
185-
h('a', { ...props }, children);
184+
Nav.Link = ({ children, href, onClick, className }: MockComponentProps) =>
185+
h('a', { href, onClick, className }, children);
186186

187-
const Navbar = ({ children, ...props }: MockComponentProps) =>
188-
h('nav', { ...props, className: 'navbar' }, children);
187+
const Navbar = ({ children, id, hidden, className }: MockComponentProps) =>
188+
h('nav', { id, hidden, className: className ? className : 'navbar', role: 'navigation' }, children);
189189

190-
Navbar.Brand = ({ children, ...props }: MockComponentProps) =>
191-
h('a', { ...props, className: 'navbar-brand' }, children);
190+
Navbar.Brand = ({ children, href, className }: MockComponentProps) =>
191+
h('a', { href, className: className ? className : 'navbar-brand' }, children);
192192

193-
Navbar.Toggle = ({ children, ...props }: MockComponentProps) =>
194-
h('button', { ...props, className: 'navbar-toggle' }, children);
193+
Navbar.Toggle = ({ children, onClick, id, 'aria-controls': ariaControls }: MockComponentProps & { 'aria-controls'?: string }) =>
194+
h('button', { onClick, id, 'aria-controls': ariaControls, className: 'navbar-toggle' }, children);
195195

196-
Navbar.Collapse = ({ children, ...props }: MockComponentProps) =>
197-
h('div', { ...props, className: 'navbar-collapse' }, children);
196+
Navbar.Collapse = ({ children, id, className }: MockComponentProps) =>
197+
h('div', { id, className: className ? className : 'navbar-collapse' }, children);
198198

199199
const InputGroup = ({ children, ...props }: MockComponentProps) =>
200200
h('div', { ...props, className: 'input-group' }, children);
@@ -286,15 +286,41 @@ vi.mock('react-bootstrap/Button', () => {
286286
};
287287
});
288288

289-
// Mock react-feather icons
289+
// Direct import path mocks for Nav and Navbar
290+
vi.mock('react-bootstrap/Nav', () => {
291+
const Nav = ({ children, className }: MockComponentProps) => h('div', { className }, children);
292+
const NavWithStatics = Object.assign(Nav, {
293+
Link: ({ children, href, onClick, className }: MockComponentProps) =>
294+
h('a', { href, onClick, className }, children),
295+
});
296+
return { default: NavWithStatics };
297+
});
298+
299+
vi.mock('react-bootstrap/Navbar', () => {
300+
const Navbar = ({ children, id, hidden, className }: MockComponentProps) =>
301+
h('nav', { id, hidden, className, role: 'navigation' }, children);
302+
const NavbarWithStatics = Object.assign(Navbar, {
303+
Brand: ({ children, href, className }: MockComponentProps) => h('a', { href, className }, children),
304+
Toggle: ({ children, onClick, id, 'aria-controls': ariaControls }: MockComponentProps & { 'aria-controls'?: string }) =>
305+
h('button', { onClick, id, 'aria-controls': ariaControls, className: 'navbar-toggle' }, children),
306+
Collapse: ({ children, id, className }: MockComponentProps) => h('div', { id, className }, children),
307+
});
308+
return { default: NavbarWithStatics };
309+
});
310+
311+
// Mock react-feather icons (use non-SVG to avoid namespace issues in jsdom)
290312
vi.mock('react-feather', () => ({
291-
ChevronDown: () => h('svg', { 'data-testid': 'chevron-down' }),
292-
ChevronUp: () => h('svg', { 'data-testid': 'chevron-up' }),
293-
Edit: () => h('svg', { 'data-testid': 'edit-icon' }),
294-
Eye: () => h('svg', { 'data-testid': 'eye-icon' }),
295-
EyeOff: () => h('svg', { 'data-testid': 'eye-off-icon' }),
296-
Monitor: () => h('svg', { 'data-testid': 'monitor-icon' }),
297-
Trash2: () => h('svg', { 'data-testid': 'trash-icon', className: 'feather-trash-2' }),
313+
ChevronDown: () => h('span', { 'data-testid': 'chevron-down' }),
314+
ChevronUp: () => h('span', { 'data-testid': 'chevron-up' }),
315+
Edit: () => h('span', { 'data-testid': 'edit-icon' }),
316+
Eye: () => h('span', { 'data-testid': 'eye-icon' }),
317+
EyeOff: () => h('span', { 'data-testid': 'eye-off-icon' }),
318+
Monitor: () => h('span', { 'data-testid': 'monitor-icon' }),
319+
Trash2: () => h('span', { 'data-testid': 'trash-icon', className: 'feather-trash-2' }),
320+
Key: () => h('span', { 'data-testid': 'key-icon' }),
321+
LogOut: () => h('span', { 'data-testid': 'logout-icon' }),
322+
Server: () => h('span', { 'data-testid': 'server-icon' }),
323+
User: () => h('span', { 'data-testid': 'user-icon' }),
298324
}));
299325

300326
// Mock i18next
@@ -365,19 +391,24 @@ vi.mock('./utils', () => ({
365391
storeSecretKeyInServiceWorker: vi.fn(),
366392
bc: { postMessage: vi.fn() },
367393
isDebugMode: { value: false },
394+
resetSecret: vi.fn(),
395+
clearSecretKeyFromServiceWorker: vi.fn().mockResolvedValue(undefined),
396+
FRONTEND_URL: '',
368397
}));
369398

370399
// Mock preact-iso
371400
vi.mock('preact-iso', () => ({
372401
useLocation: () => ({
373402
route: vi.fn(),
403+
url: '/',
374404
}),
375405
}));
376406

377407
// Mock median-js-bridge
378408
vi.mock('median-js-bridge', () => ({
379409
default: {
380410
isNativeApp: () => false,
411+
sidebar: { setItems: vi.fn() },
381412
},
382413
}));
383414

@@ -403,9 +434,18 @@ vi.mock('./components/Circle', () => ({
403434
Circle: vi.fn(() => null),
404435
}));
405436

406-
// Mock Navbar component
407-
vi.mock('./components/Navbar', () => ({
408-
logout: vi.fn(),
437+
// Mock components/Navbar to stub logout but keep other exports real
438+
vi.mock('./components/Navbar', async (importOriginal) => {
439+
const actual = await importOriginal<typeof import('./components/Navbar')>();
440+
return {
441+
...actual,
442+
logout: vi.fn(),
443+
};
444+
});
445+
446+
// Also mock direct Alert import path used by components (Navbar imports './Alert')
447+
vi.mock('./Alert', () => ({
448+
showAlert: vi.fn(),
409449
}));
410450

411451
// Mock PasswordComponent

0 commit comments

Comments
 (0)