Skip to content

Commit a39415c

Browse files
committed
frontend: add tests for devices page
1 parent 36bb8be commit a39415c

File tree

1 file changed

+280
-0
lines changed

1 file changed

+280
-0
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { render, screen, waitFor, cleanup } from '@testing-library/preact';
2+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
3+
import { DeviceList } from '../Devices';
4+
import { createRef } from 'preact';
5+
import { fetchClient, get_decrypted_secret } from '../../utils';
6+
import { Base64 } from 'js-base64';
7+
import sodium from 'libsodium-wrappers';
8+
import { showAlert } from '../../components/Alert';
9+
10+
describe('Devices.tsx - DeviceList', () => {
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
});
14+
15+
afterEach(() => {
16+
cleanup();
17+
});
18+
19+
it('renders empty state when loading devices fails', async () => {
20+
(get_decrypted_secret as unknown as Mock).mockResolvedValue(undefined);
21+
(fetchClient.GET as unknown as Mock).mockResolvedValue({
22+
data: undefined,
23+
error: 'failed',
24+
response: { status: 500 },
25+
});
26+
27+
render(<DeviceList />);
28+
29+
expect(await screen.findByText('no_devices')).toBeInTheDocument();
30+
await waitFor(() => expect((showAlert as unknown as Mock)).toHaveBeenCalled());
31+
});
32+
33+
it('renders list when devices load and decrypts name/note', async () => {
34+
(Base64.toUint8Array as unknown as Mock).mockReturnValue(new Uint8Array([1, 2, 3]));
35+
(sodium.crypto_box_seal_open as unknown as Mock).mockImplementation(() => new TextEncoder().encode('decoded'));
36+
37+
(fetchClient.GET as unknown as Mock).mockResolvedValue({
38+
data: [
39+
{
40+
id: 'dev-1',
41+
uid: 123,
42+
name: 'b64name',
43+
note: 'b64note',
44+
status: 'Connected',
45+
port: 1,
46+
valid: true,
47+
last_state_change: null,
48+
firmware_version: '1.0.0',
49+
},
50+
],
51+
error: undefined,
52+
response: { status: 200 },
53+
});
54+
55+
const { container, unmount } = render(<DeviceList />);
56+
57+
await waitFor(() => {
58+
expect(screen.queryByText('no_devices')).toBeNull();
59+
});
60+
61+
expect(container.querySelector('table')).toBeTruthy();
62+
63+
unmount();
64+
});
65+
66+
it('decryptNote returns expected values', () => {
67+
const decryptNote = DeviceList.prototype.decryptNote.bind({});
68+
69+
expect(decryptNote(undefined as unknown as string)).toBe('');
70+
expect(decryptNote(null as unknown as string)).toBe('');
71+
72+
(Base64.toUint8Array as unknown as Mock).mockReturnValue(new Uint8Array([1]));
73+
(sodium.crypto_box_seal_open as unknown as Mock).mockReturnValue(new TextEncoder().encode('hello'));
74+
expect(decryptNote('b64')).toBe('hello');
75+
76+
(sodium.crypto_box_seal_open as unknown as Mock).mockImplementation(() => { throw new Error('bad'); });
77+
expect(decryptNote('b64')).toBeUndefined();
78+
});
79+
80+
it('decrypt_name returns expected values', () => {
81+
const decryptName = DeviceList.prototype.decrypt_name.bind({});
82+
83+
expect(decryptName('')).toBe('');
84+
85+
(Base64.toUint8Array as unknown as Mock).mockReturnValue(new Uint8Array([2]));
86+
(sodium.crypto_box_seal_open as unknown as Mock).mockReturnValue(new TextEncoder().encode('world'));
87+
expect(decryptName('b64')).toBe('world');
88+
89+
(sodium.crypto_box_seal_open as unknown as Mock).mockImplementation(() => { throw new Error('bad'); });
90+
expect(decryptName('b64')).toBeUndefined();
91+
});
92+
93+
it('formatLastStateChange maps timestamps to human-readable keys', () => {
94+
const t = (key: string) => key;
95+
const format = DeviceList.prototype.formatLastStateChange.bind({});
96+
97+
vi.useFakeTimers();
98+
vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
99+
100+
expect(format(t, Date.now() / 1000)).toBe('time_just_now');
101+
102+
expect(format(t, (Date.now() - 10 * 60 * 1000) / 1000)).toBe('time_minutes_ago');
103+
104+
expect(format(t, (Date.now() - 3 * 60 * 60 * 1000) / 1000)).toBe('time_hours_ago');
105+
106+
expect(format(t, (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000)).toBe('time_days_ago');
107+
108+
const older = format(t, (Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000);
109+
expect(typeof older).toBe('string');
110+
111+
vi.useRealTimers();
112+
});
113+
114+
it('connection_possible returns false for disconnected or invalid devices', () => {
115+
const connectionPossible = DeviceList.prototype.connection_possible.bind({});
116+
117+
expect(connectionPossible({ status: 'Connected', valid: true } as any)).toBe(true);
118+
expect(connectionPossible({ status: 'Disconnected', valid: true } as any)).toBe(false);
119+
expect(connectionPossible({ status: 'Connected', valid: false } as any)).toBe(false);
120+
});
121+
122+
it('connect_to_charger routes to device details', async () => {
123+
const connect = DeviceList.prototype.connect_to_charger.bind({});
124+
const route = vi.fn();
125+
await connect({ id: 'abc' } as any, route);
126+
expect(route).toHaveBeenCalledWith('/devices/abc');
127+
});
128+
129+
it('setSort toggles sequence and sorts devices', async () => {
130+
// Prevent constructor-triggered updates from interfering
131+
const initSpy = vi.spyOn(DeviceList.prototype, 'updateChargers').mockResolvedValue(undefined as any);
132+
const ref = createRef<DeviceList>();
133+
render(<DeviceList ref={ref} />);
134+
initSpy.mockRestore();
135+
136+
// Seed devices
137+
const devices = [
138+
{ id: 'a', uid: 2, name: 'Bravo', status: 'Connected', note: '', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
139+
{ id: 'b', uid: 1, name: 'Alpha', status: 'Connected', note: '', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
140+
];
141+
ref.current!.setState({ devices, sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: false, editNote: '', editChargerIdx: 0 });
142+
await waitFor(() => expect(ref.current!.state.devices.length).toBe(2));
143+
144+
// First click -> sort by name asc
145+
ref.current!.setSort('name');
146+
await waitFor(() => expect(ref.current!.state.sortColumn).toBe('name'));
147+
expect(ref.current!.state.sortSequence).toBe('asc');
148+
expect(ref.current!.state.devices.map(d => d.name)).toEqual(['Alpha', 'Bravo']);
149+
150+
// Second click -> name desc
151+
ref.current!.setSort('name');
152+
await waitFor(() => expect(ref.current!.state.sortSequence).toBe('desc'));
153+
expect(ref.current!.state.devices.map(d => d.name)).toEqual(['Bravo', 'Alpha']);
154+
155+
// Third click -> none (defaults to name asc)
156+
ref.current!.setSort('name');
157+
await waitFor(() => expect(ref.current!.state.sortColumn).toBe('none'));
158+
expect(ref.current!.state.devices.map(d => d.name)).toEqual(['Alpha', 'Bravo']);
159+
});
160+
161+
it('setMobileSort toggles between selected and none', async () => {
162+
const ref = createRef<DeviceList>();
163+
render(<DeviceList ref={ref} />);
164+
ref.current!.setState({ devices: [], sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: false, editNote: '', editChargerIdx: 0 });
165+
166+
ref.current!.setMobileSort('uid');
167+
await waitFor(() => expect(ref.current!.state.sortColumn).toBe('uid'));
168+
169+
ref.current!.setMobileSort('uid');
170+
await waitFor(() => expect(ref.current!.state.sortColumn).toBe('none'));
171+
});
172+
173+
it('handleDelete and handleDeleteConfirm remove device on success', async () => {
174+
const initSpy = vi.spyOn(DeviceList.prototype, 'updateChargers').mockResolvedValue(undefined as any);
175+
const ref = createRef<DeviceList>();
176+
render(<DeviceList ref={ref} />);
177+
initSpy.mockRestore();
178+
const devices = [
179+
{ id: 'x', uid: 10, name: 'X', status: 'Connected', note: '', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
180+
{ id: 'y', uid: 11, name: 'Y', status: 'Connected', note: '', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
181+
];
182+
ref.current!.setState({ devices, sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: false, editNote: '', editChargerIdx: 0 });
183+
184+
ref.current!.handleDelete(devices[0]);
185+
await waitFor(() => expect(ref.current!.state.showDeleteModal).toBe(true));
186+
187+
(fetchClient.DELETE as unknown as Mock).mockResolvedValue({ response: { status: 200 } });
188+
await ref.current!.handleDeleteConfirm();
189+
await waitFor(() => expect(ref.current!.state.showDeleteModal).toBe(false));
190+
expect(ref.current!.state.devices.map(d => d.id)).toEqual(['y']);
191+
});
192+
193+
it('handleEditNote flows: submit updates note and cancel resets', async () => {
194+
const initSpy = vi.spyOn(DeviceList.prototype, 'updateChargers').mockResolvedValue(undefined as any);
195+
const ref = createRef<DeviceList>();
196+
render(<DeviceList ref={ref} />);
197+
initSpy.mockRestore();
198+
const devices = [
199+
{ id: 'z', uid: 5, name: 'Z', status: 'Connected', note: 'old', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
200+
];
201+
ref.current!.setState({ devices, sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: false, editNote: '', editChargerIdx: 0 });
202+
await waitFor(() => expect(ref.current!.state.devices.length).toBe(1));
203+
204+
ref.current!.handleEditNote(devices[0], 0);
205+
await waitFor(() => expect(ref.current!.state.showEditNoteModal).toBe(true));
206+
207+
(sodium.crypto_box_seal as unknown as Mock).mockReturnValue(new Uint8Array([9, 9]));
208+
(fetchClient.POST as unknown as Mock).mockResolvedValue({ error: undefined });
209+
const evt = { preventDefault: vi.fn() } as unknown as Event;
210+
ref.current!.setState({ editNote: 'new', editChargerIdx: 0 });
211+
await ref.current!.handleEditNoteSubmit(evt);
212+
await waitFor(() => expect(ref.current!.state.devices[0].note).toBe('new'));
213+
expect(ref.current!.state.showEditNoteModal).toBe(false);
214+
215+
ref.current!.handleEditNote(devices[0], 0);
216+
await waitFor(() => expect(ref.current!.state.showEditNoteModal).toBe(true));
217+
ref.current!.handleEditNoteCancel();
218+
await waitFor(() => expect(ref.current!.state.showEditNoteModal).toBe(false));
219+
expect(ref.current!.state.editNote).toBe('');
220+
expect(ref.current!.state.editChargerIdx).toBe(-1);
221+
});
222+
223+
it('handleEditNoteSubmit shows alert on error', async () => {
224+
const initSpy = vi.spyOn(DeviceList.prototype, 'updateChargers').mockResolvedValue(undefined as any);
225+
const ref = createRef<DeviceList>();
226+
render(<DeviceList ref={ref} />);
227+
initSpy.mockRestore();
228+
const devices = [
229+
{ id: 'n1', uid: 1, name: 'Name', status: 'Connected', note: 'old', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
230+
];
231+
ref.current!.setState({ devices, sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: true, editNote: 'upd', editChargerIdx: 0 });
232+
await waitFor(() => expect(ref.current!.state.devices.length).toBe(1));
233+
await waitFor(() => expect(ref.current!.state.showEditNoteModal).toBe(true));
234+
(sodium.crypto_box_seal as unknown as Mock).mockReturnValue(new Uint8Array([1]));
235+
(fetchClient.POST as unknown as Mock).mockResolvedValue({ error: 'err' });
236+
const evt = { preventDefault: vi.fn() } as unknown as Event;
237+
await ref.current!.handleEditNoteSubmit(evt);
238+
await waitFor(() => expect((showAlert as unknown as Mock)).toHaveBeenCalled());
239+
});
240+
241+
it('updateChargers sets devices to Disconnected on network error', async () => {
242+
const initSpy = vi.spyOn(DeviceList.prototype, 'updateChargers').mockResolvedValue(undefined as any);
243+
const ref = createRef<DeviceList>();
244+
render(<DeviceList ref={ref} />);
245+
initSpy.mockRestore();
246+
const devices = [
247+
{ id: 'u', uid: 7, name: 'U', status: 'Connected', note: '', port: 0, valid: true, last_state_change: null, firmware_version: '1' },
248+
];
249+
ref.current!.setState({ devices, sortColumn: 'none', sortSequence: 'asc', showDeleteModal: false, showEditNoteModal: false, editNote: '', editChargerIdx: 0 });
250+
await waitFor(() => expect(ref.current!.state.devices.length).toBe(1));
251+
252+
(fetchClient.GET as unknown as Mock).mockImplementation(() => { throw new Error('Network fail'); });
253+
await ref.current!.updateChargers();
254+
expect(ref.current!.state.devices[0].status).toBe('Disconnected');
255+
});
256+
257+
it('updateChargers marks device invalid when decryption fails', async () => {
258+
const ref = createRef<DeviceList>();
259+
render(<DeviceList ref={ref} />);
260+
(Base64.toUint8Array as unknown as Mock).mockReturnValue(new Uint8Array([1]));
261+
(sodium.crypto_box_seal_open as unknown as Mock).mockImplementation(() => { throw new Error('bad decrypt'); });
262+
(fetchClient.GET as unknown as Mock).mockResolvedValue({
263+
data: [{ id: 'd', uid: 2, name: 'x', note: 'y', status: 'Connected', port: 0, valid: true, last_state_change: null, firmware_version: '1' }],
264+
error: undefined,
265+
response: { status: 200 },
266+
});
267+
await ref.current!.updateChargers();
268+
expect(ref.current!.state.devices[0].valid).toBe(false);
269+
expect(ref.current!.state.devices[0].name).toBe('');
270+
expect(typeof ref.current!.state.devices[0].note).toBe('string');
271+
});
272+
273+
it('componentWillUnmount clears the interval', () => {
274+
const ref = createRef<DeviceList>();
275+
render(<DeviceList ref={ref} />);
276+
const spy = vi.spyOn(global, 'clearInterval');
277+
ref.current!.componentWillUnmount();
278+
expect(spy).toHaveBeenCalled();
279+
});
280+
});

0 commit comments

Comments
 (0)