WellNuo/__tests__/services/ble/errors.test.ts
Sergei 6960f248e0 Implement comprehensive BLE error handling system
- Add BLEError class with error codes, severity levels, and recovery actions
- Create error types for connection, permission, communication, WiFi, and sensor errors
- Add user-friendly error messages with localized titles
- Implement BLELogger for consistent logging with batch progress tracking
- Add parseBLEError utility to parse native BLE errors into typed BLEErrors
- Update BLEManager to use new error types with proper logging
- Update MockBLEManager to match error handling behavior for consistency
- Add comprehensive tests for error handling utilities (41 tests passing)

This enables proper error categorization, user-friendly messages, and
recovery suggestions for BLE operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-01 09:19:38 -08:00

412 lines
14 KiB
TypeScript

import {
BLEError,
BLEErrorCode,
BLEErrorSeverity,
BLERecoveryAction,
BLE_ERROR_MESSAGES,
BLE_RECOVERY_ACTIONS,
parseBLEError,
isBLEError,
getErrorInfo,
getRecoveryActionLabel,
BLELogger,
} from '@/services/ble/errors';
describe('BLEError', () => {
describe('constructor', () => {
it('creates error with correct code and default message', () => {
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED);
expect(error.code).toBe(BLEErrorCode.CONNECTION_FAILED);
expect(error.name).toBe('BLEError');
expect(error.userMessage.title).toBe('Connection Failed');
expect(error.userMessage.message).toContain('Could not connect');
expect(error.timestamp).toBeGreaterThan(0);
});
it('creates error with custom message', () => {
const error = new BLEError(BLEErrorCode.CONNECTION_TIMEOUT, {
message: 'Custom timeout message',
});
expect(error.message).toBe('Custom timeout message');
expect(error.userMessage.title).toBe('Connection Timeout');
});
it('creates error with device context', () => {
const error = new BLEError(BLEErrorCode.DEVICE_NOT_FOUND, {
deviceId: 'device-123',
deviceName: 'WP_497_81a14c',
});
expect(error.deviceId).toBe('device-123');
expect(error.deviceName).toBe('WP_497_81a14c');
});
it('wraps original error', () => {
const originalError = new Error('Native BLE error');
const error = new BLEError(BLEErrorCode.COMMAND_FAILED, {
originalError,
});
expect(error.originalError).toBe(originalError);
});
it('sets correct severity for critical errors', () => {
const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED);
const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED);
expect(permissionError.severity).toBe(BLEErrorSeverity.CRITICAL);
expect(bluetoothError.severity).toBe(BLEErrorSeverity.CRITICAL);
});
it('sets correct severity for warning errors', () => {
const busyError = new BLEError(BLEErrorCode.DEVICE_BUSY);
const scanError = new BLEError(BLEErrorCode.WIFI_SCAN_IN_PROGRESS);
expect(busyError.severity).toBe(BLEErrorSeverity.WARNING);
expect(scanError.severity).toBe(BLEErrorSeverity.WARNING);
});
it('sets recovery actions from mapping', () => {
const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED);
const passwordError = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
expect(connectionError.recoveryActions).toContain(BLERecoveryAction.RETRY);
expect(connectionError.recoveryActions).toContain(BLERecoveryAction.MOVE_CLOSER);
expect(passwordError.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD);
});
});
describe('toLogString', () => {
it('formats error for logging with device name', () => {
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, {
deviceName: 'WP_497_81a14c',
message: 'Connection failed',
});
const logString = error.toLogString();
expect(logString).toContain('[BLE]');
expect(logString).toContain('[BLE_CONNECTION_FAILED]');
expect(logString).toContain('[WP_497_81a14c]');
expect(logString).toContain('Connection failed');
});
it('uses deviceId when deviceName is not available', () => {
const error = new BLEError(BLEErrorCode.COMMAND_TIMEOUT, {
deviceId: 'device-123',
});
const logString = error.toLogString();
expect(logString).toContain('[device-123]');
});
});
describe('isRetryable', () => {
it('returns true for retryable errors', () => {
const connectionError = new BLEError(BLEErrorCode.CONNECTION_FAILED);
const timeoutError = new BLEError(BLEErrorCode.COMMAND_TIMEOUT);
expect(connectionError.isRetryable()).toBe(true);
expect(timeoutError.isRetryable()).toBe(true);
});
it('returns false for non-retryable errors', () => {
const cancelledError = new BLEError(BLEErrorCode.OPERATION_CANCELLED);
const alreadyConnectedError = new BLEError(BLEErrorCode.ALREADY_CONNECTED);
expect(cancelledError.isRetryable()).toBe(false);
expect(alreadyConnectedError.isRetryable()).toBe(false);
});
});
describe('isSkippable', () => {
it('returns true for skippable errors in batch operations', () => {
const commandError = new BLEError(BLEErrorCode.COMMAND_FAILED);
const rebootError = new BLEError(BLEErrorCode.SENSOR_REBOOT_FAILED);
expect(commandError.isSkippable()).toBe(true);
expect(rebootError.isSkippable()).toBe(true);
});
it('returns false for non-skippable errors', () => {
const permissionError = new BLEError(BLEErrorCode.PERMISSION_DENIED);
const bluetoothError = new BLEError(BLEErrorCode.BLUETOOTH_DISABLED);
expect(permissionError.isSkippable()).toBe(false);
expect(bluetoothError.isSkippable()).toBe(false);
});
});
});
describe('parseBLEError', () => {
it('parses permission denied errors', () => {
const error = new Error('Bluetooth permissions not granted');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.PERMISSION_DENIED);
});
it('parses bluetooth disabled errors', () => {
const error = new Error('Bluetooth is disabled');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED);
});
it('parses timeout errors', () => {
const error = new Error('Connection timeout');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.CONNECTION_TIMEOUT);
});
it('parses connection in progress errors', () => {
const error = new Error('Connection already in progress');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.CONNECTION_IN_PROGRESS);
});
it('parses WiFi password incorrect errors', () => {
const error = new Error('WiFi password is incorrect');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
});
it('parses WiFi network not found errors', () => {
const error = new Error('WiFi network not found');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.WIFI_NETWORK_NOT_FOUND);
});
it('parses device disconnected errors', () => {
const error = new Error('Device disconnected unexpectedly');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.DEVICE_DISCONNECTED);
});
it('parses cancelled operation errors', () => {
const error = new Error('Operation was cancelled');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.OPERATION_CANCELLED);
});
it('includes device context when provided', () => {
const error = new Error('Connection timeout');
const bleError = parseBLEError(error, {
deviceId: 'device-123',
deviceName: 'WP_497_81a14c',
});
expect(bleError.deviceId).toBe('device-123');
expect(bleError.deviceName).toBe('WP_497_81a14c');
});
it('defaults to unknown error for unrecognized messages', () => {
const error = new Error('Some random error message');
const bleError = parseBLEError(error);
expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR);
});
it('handles non-Error objects', () => {
const bleError = parseBLEError('string error');
expect(bleError).toBeInstanceOf(BLEError);
expect(bleError.code).toBe(BLEErrorCode.UNKNOWN_ERROR);
});
});
describe('isBLEError', () => {
it('returns true for BLEError instances', () => {
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED);
expect(isBLEError(error)).toBe(true);
});
it('returns false for regular Error instances', () => {
const error = new Error('Regular error');
expect(isBLEError(error)).toBe(false);
});
it('returns false for non-error objects', () => {
expect(isBLEError('string')).toBe(false);
expect(isBLEError(null)).toBe(false);
expect(isBLEError(undefined)).toBe(false);
expect(isBLEError({})).toBe(false);
});
});
describe('getErrorInfo', () => {
it('extracts info from BLEError', () => {
const error = new BLEError(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
const info = getErrorInfo(error);
expect(info.code).toBe(BLEErrorCode.WIFI_PASSWORD_INCORRECT);
expect(info.title).toBe('Wrong Password');
expect(info.message).toContain('incorrect');
expect(info.severity).toBe(BLEErrorSeverity.ERROR);
expect(info.recoveryActions).toContain(BLERecoveryAction.CHECK_WIFI_PASSWORD);
expect(info.isRetryable).toBe(true);
});
it('parses and extracts info from regular Error', () => {
const error = new Error('Bluetooth is disabled');
const info = getErrorInfo(error);
expect(info.code).toBe(BLEErrorCode.BLUETOOTH_DISABLED);
expect(info.title).toBe('Bluetooth Disabled');
expect(info.severity).toBe(BLEErrorSeverity.CRITICAL);
expect(info.recoveryActions).toContain(BLERecoveryAction.ENABLE_BLUETOOTH);
});
});
describe('getRecoveryActionLabel', () => {
it('returns correct labels for all actions', () => {
expect(getRecoveryActionLabel(BLERecoveryAction.RETRY)).toBe('Retry');
expect(getRecoveryActionLabel(BLERecoveryAction.SKIP)).toBe('Skip');
expect(getRecoveryActionLabel(BLERecoveryAction.CANCEL)).toBe('Cancel');
expect(getRecoveryActionLabel(BLERecoveryAction.ENABLE_BLUETOOTH)).toBe('Enable Bluetooth');
expect(getRecoveryActionLabel(BLERecoveryAction.GRANT_PERMISSIONS)).toBe('Grant Permission');
expect(getRecoveryActionLabel(BLERecoveryAction.MOVE_CLOSER)).toBe('Move Closer');
expect(getRecoveryActionLabel(BLERecoveryAction.CHECK_WIFI_PASSWORD)).toBe('Check Password');
expect(getRecoveryActionLabel(BLERecoveryAction.CONTACT_SUPPORT)).toBe('Contact Support');
});
});
describe('BLE_ERROR_MESSAGES', () => {
it('has messages for all error codes', () => {
const errorCodes = Object.values(BLEErrorCode);
for (const code of errorCodes) {
expect(BLE_ERROR_MESSAGES[code]).toBeDefined();
expect(BLE_ERROR_MESSAGES[code].title).toBeTruthy();
expect(BLE_ERROR_MESSAGES[code].message).toBeTruthy();
}
});
});
describe('BLE_RECOVERY_ACTIONS', () => {
it('has recovery actions for all error codes', () => {
const errorCodes = Object.values(BLEErrorCode);
for (const code of errorCodes) {
expect(BLE_RECOVERY_ACTIONS[code]).toBeDefined();
expect(Array.isArray(BLE_RECOVERY_ACTIONS[code])).toBe(true);
}
});
});
describe('BLELogger', () => {
// Spy on console methods
const consoleSpy = {
log: jest.spyOn(console, 'log').mockImplementation(),
warn: jest.spyOn(console, 'warn').mockImplementation(),
error: jest.spyOn(console, 'error').mockImplementation(),
};
beforeEach(() => {
jest.clearAllMocks();
BLELogger.enable();
});
afterAll(() => {
consoleSpy.log.mockRestore();
consoleSpy.warn.mockRestore();
consoleSpy.error.mockRestore();
});
it('logs messages with BLE prefix', () => {
BLELogger.log('Test message');
expect(consoleSpy.log).toHaveBeenCalledWith('[BLE] Test message', '');
});
it('logs warnings with BLE prefix', () => {
BLELogger.warn('Warning message');
expect(consoleSpy.warn).toHaveBeenCalledWith('[BLE] Warning message', '');
});
it('logs errors with BLE prefix', () => {
BLELogger.error('Error message');
expect(consoleSpy.error).toHaveBeenCalledWith('[BLE] Error message', '');
});
it('logs BLEError with formatted string', () => {
const error = new BLEError(BLEErrorCode.CONNECTION_FAILED, {
deviceName: 'WP_497',
});
BLELogger.error('Failed', error);
expect(consoleSpy.error).toHaveBeenCalled();
const loggedMessage = consoleSpy.error.mock.calls[0][0];
expect(loggedMessage).toContain('[BLE]');
expect(loggedMessage).toContain('[BLE_CONNECTION_FAILED]');
expect(loggedMessage).toContain('[WP_497]');
});
it('does not log when disabled', () => {
BLELogger.disable();
BLELogger.log('Should not appear');
BLELogger.warn('Should not appear');
BLELogger.error('Should not appear');
expect(consoleSpy.log).not.toHaveBeenCalled();
expect(consoleSpy.warn).not.toHaveBeenCalled();
expect(consoleSpy.error).not.toHaveBeenCalled();
});
describe('logBatchProgress', () => {
it('logs batch progress with success indicator', () => {
BLELogger.logBatchProgress(1, 5, 'WP_497', 'connected', true);
expect(consoleSpy.log).toHaveBeenCalled();
const loggedMessage = consoleSpy.log.mock.calls[0][0];
expect(loggedMessage).toContain('[1/5]');
expect(loggedMessage).toContain('WP_497');
expect(loggedMessage).toContain('✓');
expect(loggedMessage).toContain('connected');
});
it('logs batch progress with failure indicator', () => {
BLELogger.logBatchProgress(2, 5, 'WP_498', 'connection failed', false);
expect(consoleSpy.log).toHaveBeenCalled();
const loggedMessage = consoleSpy.log.mock.calls[0][0];
expect(loggedMessage).toContain('[2/5]');
expect(loggedMessage).toContain('✗');
});
it('logs batch progress with duration', () => {
BLELogger.logBatchProgress(3, 5, 'WP_499', 'SUCCESS', true, 8500);
expect(consoleSpy.log).toHaveBeenCalled();
const loggedMessage = consoleSpy.log.mock.calls[0][0];
expect(loggedMessage).toContain('(8.5s)');
});
});
describe('logBatchSummary', () => {
it('logs batch summary with counts', () => {
BLELogger.logBatchSummary(5, 4, 1, 45000);
expect(consoleSpy.log).toHaveBeenCalled();
const loggedMessage = consoleSpy.log.mock.calls[0][0];
expect(loggedMessage).toContain('4/5 succeeded');
expect(loggedMessage).toContain('1 failed');
expect(loggedMessage).toContain('45.0s');
});
});
});