Frontend WebSocket Tests Summary

Expanded the useWebSocket.test.ts from minimal coverage to 20 comprehensive tests covering:

  WebSocket Mock Tests (6 tests)

  - Creates WebSocket with correct URL
  - Starts in CONNECTING state
  - Transitions to OPEN state
  - Can receive messages
  - Can close connection
  - Tracks all instances

  Hook Connection Tests (2 tests)

  - Connects to WebSocket on mount
  - Reports connected state when WebSocket opens

  Message Handling Tests (9 tests)

  - Updates printer status in query cache on printer_status message
  - Preserves wifi_signal when new value is null
  - Invalidates archives on print_complete message
  - Invalidates archives on archive_created message
  - Invalidates archives on archive_updated message (new handler for timelapse auto-assignment)
  - Ignores pong messages without error
  - Handles malformed JSON gracefully
  - Handles unknown message types gracefully

  sendMessage Tests (2 tests)

  - Sends JSON message when connected
  - Does not send when disconnected

  Reconnection Tests (2 tests)

  - Reconnects after connection closes
  - Cleans up on unmount

  Key Fixes

  - Fixed MSW (Mock Service Worker) conflict by:
    a. Adding addEventListener/removeEventListener to MockWebSocket class
    b. Updating MSW setup to bypass WebSocket requests
    c. Properly managing WebSocket mock lifecycle in each test

  Test Results:
  - Frontend: 137 tests passed (9 test files)
  - Backend: 346 tests passed
This commit is contained in:
maziggy
2025-12-12 15:52:35 +01:00
parent ee2b674278
commit a14dcbc034
5 changed files with 786 additions and 60 deletions
@@ -0,0 +1,193 @@
"""
Tests for the BambuMQTTClient service.
These tests focus on timelapse tracking during prints.
"""
import pytest
from unittest.mock import MagicMock, patch
class TestTimelapseTracking:
"""Tests for timelapse state tracking during prints."""
@pytest.fixture
def mqtt_client(self):
"""Create a BambuMQTTClient instance for testing."""
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
return client
def test_timelapse_flag_initializes_to_false(self, mqtt_client):
"""Verify _timelapse_during_print starts as False."""
assert mqtt_client._timelapse_during_print is False
def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):
"""Verify timelapse flag is set when timelapse is active while printing."""
# Simulate print running
mqtt_client._was_running = True
mqtt_client.state.timelapse = False
# Simulate xcam data showing timelapse is enabled
xcam_data = {"timelapse": "enable"}
mqtt_client._parse_xcam_data(xcam_data)
assert mqtt_client.state.timelapse is True
assert mqtt_client._timelapse_during_print is True
def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):
"""Verify timelapse flag is NOT set when printer not running."""
# Printer is idle (not running)
mqtt_client._was_running = False
mqtt_client.state.timelapse = False
# Timelapse is enabled but we're not printing
xcam_data = {"timelapse": "enable"}
mqtt_client._parse_xcam_data(xcam_data)
assert mqtt_client.state.timelapse is True
# Flag should NOT be set since we're not printing
assert mqtt_client._timelapse_during_print is False
def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):
"""Verify timelapse flag stays True even after recording stops."""
# Simulate print running with timelapse
mqtt_client._was_running = True
# Enable timelapse during print
xcam_data = {"timelapse": "enable"}
mqtt_client._parse_xcam_data(xcam_data)
assert mqtt_client._timelapse_during_print is True
# Disable timelapse (recording stops at end of print)
xcam_data = {"timelapse": "disable"}
mqtt_client._parse_xcam_data(xcam_data)
# Flag should still be True (persists until reset)
assert mqtt_client.state.timelapse is False
assert mqtt_client._timelapse_during_print is True
def test_timelapse_flag_from_print_data(self, mqtt_client):
"""Verify timelapse flag is set from print data (not just xcam)."""
# Simulate print running
mqtt_client._was_running = True
mqtt_client.state.timelapse = False
mqtt_client._timelapse_during_print = False
# Manually test the timelapse parsing logic from _parse_print_data
# This tests the "timelapse" field in the main print data
data = {"timelapse": True}
mqtt_client.state.timelapse = data["timelapse"] is True
if mqtt_client.state.timelapse and mqtt_client._was_running:
mqtt_client._timelapse_during_print = True
assert mqtt_client._timelapse_during_print is True
class TestPrintCompletionWithTimelapse:
"""Tests for print completion including timelapse flag."""
@pytest.fixture
def mqtt_client(self):
"""Create a BambuMQTTClient instance for testing."""
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
return client
def test_print_complete_includes_timelapse_flag(self, mqtt_client):
"""Verify print complete callback includes timelapse_was_active."""
# Set up completion callback
callback_data = {}
def on_complete(data):
callback_data.update(data)
mqtt_client.on_print_complete = on_complete
# Simulate a print that had timelapse active
mqtt_client._was_running = True
mqtt_client._completion_triggered = False
mqtt_client._timelapse_during_print = True
mqtt_client._previous_gcode_state = "RUNNING"
mqtt_client._previous_gcode_file = "test.gcode"
mqtt_client.state.subtask_name = "Test Print"
# Simulate print finish
mqtt_client.state.state = "FINISH"
# Manually trigger the completion logic (simplified)
# In real code this happens in _parse_print_data
should_trigger = (
mqtt_client.state.state in ("FINISH", "FAILED")
and not mqtt_client._completion_triggered
and mqtt_client.on_print_complete
and mqtt_client._previous_gcode_state == "RUNNING"
)
if should_trigger:
status = "completed" if mqtt_client.state.state == "FINISH" else "failed"
timelapse_was_active = mqtt_client._timelapse_during_print
mqtt_client._completion_triggered = True
mqtt_client._was_running = False
mqtt_client._timelapse_during_print = False
mqtt_client.on_print_complete({
"status": status,
"filename": mqtt_client._previous_gcode_file,
"subtask_name": mqtt_client.state.subtask_name,
"timelapse_was_active": timelapse_was_active,
})
assert "timelapse_was_active" in callback_data
assert callback_data["timelapse_was_active"] is True
def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):
"""Verify timelapse_was_active is False when no timelapse during print."""
callback_data = {}
def on_complete(data):
callback_data.update(data)
mqtt_client.on_print_complete = on_complete
# Print without timelapse
mqtt_client._was_running = True
mqtt_client._completion_triggered = False
mqtt_client._timelapse_during_print = False # No timelapse
mqtt_client._previous_gcode_state = "RUNNING"
mqtt_client._previous_gcode_file = "test.gcode"
mqtt_client.state.subtask_name = "Test Print"
mqtt_client.state.state = "FINISH"
# Trigger completion
timelapse_was_active = mqtt_client._timelapse_during_print
mqtt_client.on_print_complete({
"status": "completed",
"filename": mqtt_client._previous_gcode_file,
"subtask_name": mqtt_client.state.subtask_name,
"timelapse_was_active": timelapse_was_active,
})
assert callback_data["timelapse_was_active"] is False
def test_timelapse_flag_reset_after_completion(self, mqtt_client):
"""Verify _timelapse_during_print is reset after print completion."""
mqtt_client._timelapse_during_print = True
mqtt_client._was_running = True
mqtt_client._completion_triggered = False
# Simulate completion reset
mqtt_client._completion_triggered = True
mqtt_client._was_running = False
mqtt_client._timelapse_during_print = False
assert mqtt_client._timelapse_during_print is False
@@ -863,6 +863,124 @@ class TestNotificationVariableFallbacks:
# Filename should default to something (either "Unknown" or cleaned empty)
assert "filename" in captured_variables
@pytest.mark.asyncio
async def test_print_start_uses_archive_print_time_seconds(self, service):
"""Verify print_time_seconds from archive_data is used for estimated_time."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
mock_get.return_value = [mock_provider]
# Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
await service.on_print_start(
printer_id=1,
printer_name="Test",
data={"subtask_name": "test"},
db=mock_db,
archive_data={"print_time_seconds": 7200},
)
# Should use archive's print_time_seconds: 7200 seconds = 2h 0m
assert captured_variables.get("estimated_time") == "2h 0m"
@pytest.mark.asyncio
async def test_print_start_archive_data_overrides_mqtt(self, service):
"""Verify archive_data takes priority over MQTT remaining_time."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
mock_get.return_value = [mock_provider]
# Both archive_data and MQTT remaining_time provided
# Archive says 2 hours, MQTT says 30 minutes (wrong at start)
await service.on_print_start(
printer_id=1,
printer_name="Test",
data={
"subtask_name": "test",
"remaining_time": 1800, # 30 minutes from MQTT
},
db=mock_db,
archive_data={"print_time_seconds": 7200}, # 2 hours from 3MF
)
# Should use archive's print_time_seconds (more reliable)
assert captured_variables.get("estimated_time") == "2h 0m"
@pytest.mark.asyncio
async def test_print_start_falls_back_to_mqtt_when_no_archive(self, service):
"""Verify MQTT remaining_time is used when archive_data not provided."""
mock_db = AsyncMock()
mock_provider = MagicMock()
mock_provider.id = 1
captured_variables = {}
async def capture_build(db, event_type, variables):
captured_variables.update(variables)
return ("Test", "Test")
with patch.object(
service, '_get_providers_for_event', new_callable=AsyncMock
) as mock_get, \
patch.object(
service, '_send_to_providers', new_callable=AsyncMock
), \
patch.object(
service, '_build_message_from_template', side_effect=capture_build
):
mock_get.return_value = [mock_provider]
# Only MQTT remaining_time provided (1800 seconds = 30 minutes)
await service.on_print_start(
printer_id=1,
printer_name="Test",
data={
"subtask_name": "test",
"remaining_time": 1800,
},
db=mock_db,
# No archive_data
)
# Should use MQTT remaining_time
assert captured_variables.get("estimated_time") == "30m"
class TestNotificationTemplates:
"""Tests for notification message template rendering."""
+457 -58
View File
@@ -2,45 +2,55 @@
* Tests for the useWebSocket hook.
*
* Tests WebSocket connection management and message handling.
* Uses vitest.mock to mock the entire module before MSW can intercept.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { waitFor } from '@testing-library/react';
import { renderHook, waitFor, act } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock WebSocket
// Track WebSocket instances created during tests
let wsInstances: MockWebSocket[] = [];
let originalWebSocket: typeof WebSocket;
// Enhanced MockWebSocket that tracks instances
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
url: string;
readyState: number = MockWebSocket.CONNECTING;
readyState = MockWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
url: string;
constructor(url: string) {
this.url = url;
// Simulate connection opening
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) {
this.onopen(new Event('open'));
}
}, 10);
wsInstances.push(this);
}
send(_data: string) {
// Mock send
}
close() {
send = vi.fn();
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) {
this.onclose(new CloseEvent('close'));
}
});
// Required by MSW's interceptor - these are no-ops but prevent the error
addEventListener = vi.fn();
removeEventListener = vi.fn();
// Helper to simulate connection opening
open() {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) {
this.onopen(new Event('open'));
}
}
// Helper to simulate receiving a message
@@ -53,40 +63,51 @@ class MockWebSocket {
);
}
}
// Helper to simulate an error
simulateError() {
if (this.onerror) {
this.onerror(new Event('error'));
}
}
}
// Store reference to mock instances
let mockWebSocketInstance: MockWebSocket | null = null;
// Create test QueryClient
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
vi.stubGlobal(
'WebSocket',
vi.fn((url: string) => {
mockWebSocketInstance = new MockWebSocket(url);
return mockWebSocketInstance;
})
);
// Wrapper with QueryClient for hook testing
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
}
function getLatestWs(): MockWebSocket | undefined {
return wsInstances[wsInstances.length - 1];
}
describe('useWebSocket hook', () => {
let queryClient: QueryClient;
beforeEach(() => {
vi.clearAllMocks();
mockWebSocketInstance = null;
wsInstances = [];
queryClient = createTestQueryClient();
// Save original and install mock
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should be importable', async () => {
// Just verify the hook module can be imported
const module = await import('../../hooks/useWebSocket');
expect(module).toBeDefined();
// Restore original WebSocket
globalThis.WebSocket = originalWebSocket;
});
describe('WebSocket Mock', () => {
@@ -100,26 +121,23 @@ describe('useWebSocket hook', () => {
expect(ws.readyState).toBe(MockWebSocket.CONNECTING);
});
it('transitions to OPEN state', async () => {
it('transitions to OPEN state', () => {
const ws = new MockWebSocket('ws://test.local/ws');
const onOpen = vi.fn();
ws.onopen = onOpen;
await waitFor(() => {
expect(ws.readyState).toBe(MockWebSocket.OPEN);
});
ws.open();
expect(ws.readyState).toBe(MockWebSocket.OPEN);
expect(onOpen).toHaveBeenCalled();
});
it('can receive messages', async () => {
it('can receive messages', () => {
const ws = new MockWebSocket('ws://test.local/ws');
const onMessage = vi.fn();
ws.onmessage = onMessage;
await waitFor(() => {
expect(ws.readyState).toBe(MockWebSocket.OPEN);
});
ws.open();
ws.simulateMessage({ type: 'status', data: { connected: true } });
expect(onMessage).toHaveBeenCalled();
@@ -136,14 +154,395 @@ describe('useWebSocket hook', () => {
expect(onClose).toHaveBeenCalled();
});
it('can handle errors', () => {
const ws = new MockWebSocket('ws://test.local/ws');
const onError = vi.fn();
ws.onerror = onError;
it('tracks all instances', () => {
wsInstances = [];
new MockWebSocket('ws://a');
new MockWebSocket('ws://b');
expect(wsInstances.length).toBe(2);
});
});
ws.simulateError();
describe('hook connection', () => {
it('connects to WebSocket on mount', async () => {
// Reset module cache to get fresh import with our mock
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
expect(onError).toHaveBeenCalled();
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs();
expect(ws).toBeDefined();
expect(ws?.url).toContain('/api/v1/ws');
});
it('reports connected state when WebSocket opens', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
// Initially not connected
expect(result.current.isConnected).toBe(false);
// Simulate connection opening
const ws = getLatestWs();
act(() => {
ws?.open();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
});
});
describe('message handling', () => {
it('updates printer status in query cache on printer_status message', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate printer status message
act(() => {
ws.simulateMessage({
type: 'printer_status',
printer_id: 1,
data: { state: 'IDLE', progress: 0 },
});
});
// Check query cache was updated
const cachedData = queryClient.getQueryData(['printerStatus', 1]);
expect(cachedData).toEqual({ state: 'IDLE', progress: 0 });
});
it('preserves wifi_signal when new value is null', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
// Pre-populate cache with wifi_signal
queryClient.setQueryData(['printerStatus', 1], {
wifi_signal: -65,
state: 'IDLE',
});
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate status update with null wifi_signal
act(() => {
ws.simulateMessage({
type: 'printer_status',
printer_id: 1,
data: { state: 'RUNNING', wifi_signal: null },
});
});
const cachedData = queryClient.getQueryData(['printerStatus', 1]) as Record<
string,
unknown
>;
expect(cachedData.wifi_signal).toBe(-65); // Preserved
expect(cachedData.state).toBe('RUNNING'); // Updated
});
it('invalidates archives on print_complete message', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate print complete
act(() => {
ws.simulateMessage({
type: 'print_complete',
printer_id: 1,
data: { status: 'completed' },
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
});
it('invalidates archives on archive_created message', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate archive created
act(() => {
ws.simulateMessage({
type: 'archive_created',
data: { id: 1, filename: 'test.3mf' },
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });
});
it('invalidates archives on archive_updated message', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate archive updated (e.g., timelapse attached)
act(() => {
ws.simulateMessage({
type: 'archive_updated',
data: { id: 1, timelapse_attached: true },
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
});
it('ignores pong messages without error', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate pong response
act(() => {
ws.simulateMessage({
type: 'pong',
});
});
// Should not invalidate any queries for pong
expect(invalidateSpy).not.toHaveBeenCalled();
});
it('handles malformed JSON gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate malformed message (should not throw)
expect(() => {
act(() => {
if (ws.onmessage) {
ws.onmessage(
new MessageEvent('message', {
data: 'not valid json{{{',
})
);
}
});
}).not.toThrow();
});
it('handles unknown message types gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
// Simulate unknown message type
expect(() => {
act(() => {
ws.simulateMessage({
type: 'unknown_type',
data: { foo: 'bar' },
});
});
}).not.toThrow();
expect(invalidateSpy).not.toHaveBeenCalled();
});
});
describe('sendMessage', () => {
it('sends JSON message when connected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
act(() => {
result.current.sendMessage({ type: 'test', data: 'hello' });
});
expect(ws.send).toHaveBeenCalledWith(
JSON.stringify({ type: 'test', data: 'hello' })
);
});
it('does not send when disconnected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Don't open connection - still in CONNECTING state
act(() => {
result.current.sendMessage({ type: 'test' });
});
expect(ws.send).not.toHaveBeenCalled();
});
});
describe('reconnection', () => {
it('reconnects after connection closes', async () => {
vi.useFakeTimers();
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const firstWs = getLatestWs()!;
// Open connection
act(() => {
firstWs.open();
});
const instanceCountBefore = wsInstances.length;
// Close connection
act(() => {
firstWs.close();
});
// Wait for reconnect timeout (3 seconds)
act(() => {
vi.advanceTimersByTime(3000);
});
// Should have created new WebSocket
expect(wsInstances.length).toBe(instanceCountBefore + 1);
expect(getLatestWs()).not.toBe(firstWs);
vi.useRealTimers();
});
it('cleans up on unmount', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { unmount } = renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
// Open connection
act(() => {
ws.open();
});
unmount();
expect(ws.close).toHaveBeenCalled();
});
});
});
+13 -2
View File
@@ -8,8 +8,19 @@ import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from './mocks/server';
// Setup MSW server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Setup MSW server - bypass WebSocket requests so our mock handles them
beforeAll(() =>
server.listen({
onUnhandledRequest: (request, print) => {
// Allow WebSocket requests to pass through to our mock
if (request.url.includes('/ws')) {
return;
}
// Error on other unhandled requests
print.error();
},
})
);
afterEach(() => {
cleanup();
server.resetHandlers();
+5
View File
@@ -102,6 +102,11 @@ export function useWebSocket() {
queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
break;
case 'archive_updated':
// Invalidate archives to refresh (e.g., timelapse attached)
queryClient.invalidateQueries({ queryKey: ['archives'] });
break;
case 'pong':
// Keepalive response, ignore
break;