/** * Tests for Web Bluetooth Service * * Note: These tests mock the Web Bluetooth API since it's not available in Node.js */ // Setup TextEncoder/TextDecoder for Node.js environment BEFORE importing the module // eslint-disable-next-line @typescript-eslint/no-require-imports const util = require('util'); if (typeof global.TextEncoder === 'undefined') { global.TextEncoder = util.TextEncoder; } if (typeof global.TextDecoder === 'undefined') { global.TextDecoder = util.TextDecoder; } import { WebBluetoothManager, isWebBluetoothSupported, isBluetoothAvailable, BLE_CONFIG, BLE_COMMANDS, BLEConnectionState, WPDevice, } from '../webBluetooth'; // Mock BluetoothRemoteGATTCharacteristic class MockCharacteristic { private listeners: Map void)[]> = new Map(); private notifying = false; value: DataView | null = null; async startNotifications(): Promise { this.notifying = true; } async stopNotifications(): Promise { this.notifying = false; } addEventListener(type: string, listener: (event: Event) => void): void { const existing = this.listeners.get(type) || []; this.listeners.set(type, [...existing, listener]); } removeEventListener(type: string, listener: (event: Event) => void): void { const existing = this.listeners.get(type) || []; this.listeners.set(type, existing.filter((l) => l !== listener)); } async writeValueWithResponse(_value: ArrayBuffer): Promise { // Simulate device response after a short delay setTimeout(() => { const encoder = new TextEncoder(); const response = encoder.encode('pin|ok'); this.value = new DataView(response.buffer); const listeners = this.listeners.get('characteristicvaluechanged') || []; listeners.forEach((listener) => { listener({ target: this } as unknown as Event); }); }, 50); } // Helper for testing - simulate specific response simulateResponse(response: string): void { const encoder = new TextEncoder(); const encoded = encoder.encode(response); this.value = new DataView(encoded.buffer); const listeners = this.listeners.get('characteristicvaluechanged') || []; listeners.forEach((listener) => { listener({ target: this } as unknown as Event); }); } } // Mock BluetoothRemoteGATTService class MockService { constructor(private characteristic: MockCharacteristic) {} async getCharacteristic(_uuid: string): Promise { return this.characteristic; } } // Mock BluetoothRemoteGATTServer class MockGATTServer { connected = true; constructor(private service: MockService) {} async connect(): Promise { this.connected = true; return this; } disconnect(): void { this.connected = false; } async getPrimaryService(_uuid: string): Promise { return this.service; } } // Mock BluetoothDevice class MockBluetoothDevice { id: string; name: string; gatt: MockGATTServer; private listeners: Map void)[]> = new Map(); constructor(id: string, name: string, gattServer: MockGATTServer) { this.id = id; this.name = name; this.gatt = gattServer; } addEventListener(type: string, listener: () => void): void { const existing = this.listeners.get(type) || []; this.listeners.set(type, [...existing, listener]); } removeEventListener(type: string, listener: () => void): void { const existing = this.listeners.get(type) || []; this.listeners.set(type, existing.filter((l) => l !== listener)); } triggerDisconnection(): void { const listeners = this.listeners.get('gattserverdisconnected') || []; listeners.forEach((listener) => listener()); } } // Create mock device helper function createMockDevice(id: string, name: string): { device: MockBluetoothDevice; characteristic: MockCharacteristic } { const characteristic = new MockCharacteristic(); const service = new MockService(characteristic); const gattServer = new MockGATTServer(service); const device = new MockBluetoothDevice(id, name, gattServer); return { device, characteristic }; } describe('WebBluetoothManager', () => { let manager: WebBluetoothManager; beforeEach(() => { manager = new WebBluetoothManager(); }); afterEach(async () => { await manager.cleanup(); }); describe('isWebBluetoothSupported', () => { it('returns false when navigator.bluetooth is not available', () => { const originalNavigator = global.navigator; // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).navigator = undefined; expect(isWebBluetoothSupported()).toBe(false); global.navigator = originalNavigator; }); }); describe('BLE_CONFIG', () => { it('has correct service UUID', () => { expect(BLE_CONFIG.SERVICE_UUID).toBe('4fafc201-1fb5-459e-8fcc-c5c9c331914b'); }); it('has correct characteristic UUID', () => { expect(BLE_CONFIG.CHAR_UUID).toBe('beb5483e-36e1-4688-b7f5-ea07361b26a8'); }); it('has correct device name prefix', () => { expect(BLE_CONFIG.DEVICE_NAME_PREFIX).toBe('WP_'); }); }); describe('BLE_COMMANDS', () => { it('has correct PIN unlock command', () => { expect(BLE_COMMANDS.PIN_UNLOCK).toBe('pin|7856'); }); it('has correct WiFi list command', () => { expect(BLE_COMMANDS.GET_WIFI_LIST).toBe('w'); }); it('has correct set WiFi command', () => { expect(BLE_COMMANDS.SET_WIFI).toBe('W'); }); it('has correct WiFi status command', () => { expect(BLE_COMMANDS.GET_WIFI_STATUS).toBe('a'); }); it('has correct reboot command', () => { expect(BLE_COMMANDS.REBOOT).toBe('s'); }); }); describe('connection state management', () => { it('returns DISCONNECTED for unknown device', () => { expect(manager.getConnectionState('unknown-device')).toBe(BLEConnectionState.DISCONNECTED); }); it('tracks all connections', () => { const connections = manager.getAllConnections(); expect(connections).toBeInstanceOf(Map); expect(connections.size).toBe(0); }); }); describe('event listeners', () => { it('adds event listener', () => { const listener = jest.fn(); manager.addEventListener(listener); // Trigger an event by updating state const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; // Connect triggers state change manager.connectDevice(wpDevice); // Give time for async operations expect(listener).toHaveBeenCalled(); }); it('removes event listener', () => { const listener = jest.fn(); manager.addEventListener(listener); manager.removeEventListener(listener); // Listener should not be called after removal // (internal implementation detail - testing the interface) }); it('does not add duplicate listeners', () => { const listener = jest.fn(); manager.addEventListener(listener); manager.addEventListener(listener); manager.removeEventListener(listener); // After one removal, listener should be gone }); }); describe('connectDevice', () => { it('successfully connects to a device', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; const result = await manager.connectDevice(wpDevice); expect(result).toBe(true); expect(manager.isDeviceConnected('test-id')).toBe(true); expect(manager.getConnectionState('test-id')).toBe(BLEConnectionState.READY); }); it('prevents concurrent connections to the same device', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; // Start first connection const firstConnection = manager.connectDevice(wpDevice); // Try second connection immediately const secondConnection = manager.connectDevice(wpDevice); const [result1, result2] = await Promise.all([firstConnection, secondConnection]); // First should succeed, second should fail (or both succeed if first finished fast) expect(result1).toBe(true); // Second might succeed if first finished before it started }); it('returns true if already connected', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; // First connection await manager.connectDevice(wpDevice); // Second connection should return true immediately const result = await manager.connectDevice(wpDevice); expect(result).toBe(true); }); }); describe('disconnectDevice', () => { it('disconnects a connected device', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; await manager.connectDevice(wpDevice); expect(manager.isDeviceConnected('test-id')).toBe(true); await manager.disconnectDevice('test-id'); expect(manager.isDeviceConnected('test-id')).toBe(false); expect(manager.getConnectionState('test-id')).toBe(BLEConnectionState.DISCONNECTED); }); it('handles disconnect of non-existent device', async () => { await expect(manager.disconnectDevice('non-existent')).resolves.not.toThrow(); }); }); describe('cleanup', () => { it('cleans up all connections', async () => { const { device: device1 } = createMockDevice('test-id-1', 'WP_497_81a14c'); const { device: device2 } = createMockDevice('test-id-2', 'WP_523_81aad4'); const wpDevice1: WPDevice = { id: 'test-id-1', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device1 as unknown as BluetoothDevice, }; const wpDevice2: WPDevice = { id: 'test-id-2', name: 'WP_523_81aad4', mac: '81AAD4', rssi: -67, wellId: 523, device: device2 as unknown as BluetoothDevice, }; await manager.connectDevice(wpDevice1); await manager.connectDevice(wpDevice2); expect(manager.isDeviceConnected('test-id-1')).toBe(true); expect(manager.isDeviceConnected('test-id-2')).toBe(true); await manager.cleanup(); expect(manager.isDeviceConnected('test-id-1')).toBe(false); expect(manager.isDeviceConnected('test-id-2')).toBe(false); expect(manager.getAllConnections().size).toBe(0); }); }); describe('WiFi validation', () => { it('rejects SSID with pipe character', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; await manager.connectDevice(wpDevice); await expect(manager.setWiFi('test-id', 'Test|Network', 'password123')).rejects.toThrow( 'Network name contains invalid characters' ); }); it('rejects SSID with comma character', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; await manager.connectDevice(wpDevice); await expect(manager.setWiFi('test-id', 'Test,Network', 'password123')).rejects.toThrow( 'Network name contains invalid characters' ); }); it('rejects password with pipe character', async () => { const { device } = createMockDevice('test-id', 'WP_497_81a14c'); const wpDevice: WPDevice = { id: 'test-id', name: 'WP_497_81a14c', mac: '81A14C', rssi: -50, wellId: 497, device: device as unknown as BluetoothDevice, }; await manager.connectDevice(wpDevice); await expect(manager.setWiFi('test-id', 'TestNetwork', 'pass|word123')).rejects.toThrow( 'Password contains an invalid character' ); }); }); describe('device parsing', () => { it('correctly parses device name format', () => { // Test the parsing logic implicitly through device creation const deviceName = 'WP_497_81a14c'; // Parse well_id from name (WP_497_81a14c -> 497) const wellIdMatch = deviceName.match(/WP_(\d+)_/); const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; // Extract MAC from device name (last part after underscore) const macMatch = deviceName.match(/_([a-fA-F0-9]{6})$/); const mac = macMatch ? macMatch[1].toUpperCase() : ''; expect(wellId).toBe(497); expect(mac).toBe('81A14C'); }); it('handles device name without well_id', () => { const deviceName = 'WP_81a14c'; const wellIdMatch = deviceName.match(/WP_(\d+)_/); const wellId = wellIdMatch ? parseInt(wellIdMatch[1], 10) : undefined; expect(wellId).toBeUndefined(); }); }); }); describe('isBluetoothAvailable', () => { it('returns false when navigator.bluetooth is not available', async () => { const originalNavigator = global.navigator; // eslint-disable-next-line @typescript-eslint/no-explicit-any (global as any).navigator = undefined; const result = await isBluetoothAvailable(); expect(result).toBe(false); global.navigator = originalNavigator; }); });