diff --git a/CHANGELOG.md b/CHANGELOG.md index cf45019..b08363f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.2.0] + +### Changed +- **Default API base URL** is now `https://api.pushfire.app/functions/v1/` (was the raw Supabase functions URL). Functionally identical endpoint; keeps the backend project ref out of the SDK and logs. Override via `PushFireConfig.baseUrl` is unaffected. +- **Clearer API error logging**: failed requests now log a single `API error: -> HTTP code= msg=""` line instead of a misleading "Unexpected error" plus duplicate "X failed" lines across layers. + +### Fixed +- **Structured API exceptions are preserved**: a non-2xx response now throws a `PushFireApiException` whose `statusCode`, `code`, and `responseBody` are populated, instead of being re-wrapped into a generic exception with `statusCode == null`. + +### ⚠️ Potentially breaking +- **Request timeouts now throw `PushFireNetworkException`** with a clear "timed out after Ns" message. Previously a timeout surfaced as a generic `PushFireApiException` with an "Unexpected error" message. If you catch `PushFireApiException` specifically to handle timeouts, also catch `PushFireNetworkException` — or catch the shared base `PushFireException`. (`SocketException`/`HttpException` already mapped to `PushFireNetworkException`.) + +### Internal +- `PushFireApiClient` accepts an injectable `http.Client` for testing; added API client error/timeout/network test coverage. + ## [0.1.11] ### Added diff --git a/README.md b/README.md index ce1abee..3b28dcc 100644 --- a/README.md +++ b/README.md @@ -498,12 +498,14 @@ if (PushFireSDK.isInitialized) { | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `apiKey` | String | Yes | - | Your PushFire API key | -| `baseUrl` | String | No | `https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/` | API base URL | +| `baseUrl` | String | No | `https://api.pushfire.app/functions/v1/` | API base URL | | `enableLogging` | bool | No | `false` | Enable debug logging | | `timeoutSeconds` | int | No | `30` | Request timeout in seconds | | `authProvider` | AuthProvider | No | `AuthProvider.none` | Authentication provider for automatic subscriber management | | `requestNotificationPermission` | bool | No | `true` | Automatically request notification permissions during SDK initialization | +> **⚠️ Logging & privacy:** When `enableLogging` is `true`, the SDK logs full request and response bodies — including subscriber PII such as `email`, `phone`, `name`, and `metadata`. Logging is **off by default** and uses `dart:developer` (generally not emitted in release builds), but if you forward SDK logs anywhere (for example a crash reporter or log aggregator), do **not** do so in production builds with logging enabled. + ## Notification Permissions The PushFire SDK provides flexible notification permission handling to accommodate different app requirements and user experience strategies. diff --git a/lib/src/api/pushfire_api_client.dart b/lib/src/api/pushfire_api_client.dart index e8d8cf4..55330fe 100644 --- a/lib/src/api/pushfire_api_client.dart +++ b/lib/src/api/pushfire_api_client.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; @@ -10,8 +11,8 @@ class PushFireApiClient { final PushFireConfig _config; late final http.Client _httpClient; - PushFireApiClient(this._config) { - _httpClient = http.Client(); + PushFireApiClient(this._config, {http.Client? httpClient}) { + _httpClient = httpClient ?? http.Client(); } /// Get common headers for API requests @@ -86,21 +87,30 @@ class PushFireApiClient { response.body, ); - return _handleResponse(response); + return _handleResponse(method, endpoint, response); + } on PushFireException { + rethrow; + } on TimeoutException catch (e) { + final message = + 'Request to $method $endpoint timed out after ${_config.timeoutSeconds}s'; + PushFireLogger.error(message, e); + throw PushFireNetworkException(message, originalError: e); } on SocketException catch (e) { - PushFireLogger.error('Network error: ${e.message}', e); + PushFireLogger.error( + 'Network error during $method $endpoint: ${e.message}', e); throw PushFireNetworkException( 'Network error: ${e.message}', originalError: e, ); } on HttpException catch (e) { - PushFireLogger.error('HTTP error: ${e.message}', e); + PushFireLogger.error( + 'HTTP error during $method $endpoint: ${e.message}', e); throw PushFireNetworkException( 'HTTP error: ${e.message}', originalError: e, ); } catch (e) { - PushFireLogger.error('Unexpected error during API request', e); + PushFireLogger.error('Unexpected error during $method $endpoint', e); throw PushFireApiException( 'Unexpected error: $e', originalError: e, @@ -109,7 +119,11 @@ class PushFireApiClient { } /// Handle HTTP response - Map _handleResponse(http.Response response) { + Map _handleResponse( + String method, + String endpoint, + http.Response response, + ) { final statusCode = response.statusCode; final body = response.body; @@ -142,6 +156,9 @@ class PushFireApiClient { errorMessage = 'API request failed with status $statusCode'; } + PushFireLogger.logApiError( + method, endpoint, statusCode, errorCode, errorMessage); + throw PushFireApiException( errorMessage, code: errorCode, diff --git a/lib/src/config/pushfire_config.dart b/lib/src/config/pushfire_config.dart index bbfe011..762e31b 100644 --- a/lib/src/config/pushfire_config.dart +++ b/lib/src/config/pushfire_config.dart @@ -20,7 +20,7 @@ class PushFireConfig { const PushFireConfig({ required this.apiKey, - this.baseUrl = 'https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/', + this.baseUrl = 'https://api.pushfire.app/functions/v1/', this.enableLogging = false, this.timeoutSeconds = 30, this.authProvider = AuthProvider.none, diff --git a/lib/src/pushfire_sdk_impl.dart b/lib/src/pushfire_sdk_impl.dart index 68e668e..930c71a 100644 --- a/lib/src/pushfire_sdk_impl.dart +++ b/lib/src/pushfire_sdk_impl.dart @@ -111,9 +111,10 @@ class PushFireSDKImpl with WidgetsBindingObserver { await Firebase.initializeApp(); PushFireLogger.info('Firebase initialized successfully'); } catch (e) { - // Firebase might already be initialized, which is fine + // Firebase is most likely already initialized (common and harmless). + // If it is a real failure, downstream FCM calls will surface it. PushFireLogger.info( - 'Firebase already initialized or initialization failed: $e'); + 'Firebase initializeApp() skipped or failed (continuing): $e'); } // Initialize services @@ -137,9 +138,9 @@ class PushFireSDKImpl with WidgetsBindingObserver { case AuthProvider.supabase: PushFireLogger.info('Listening for auth state changes'); // Listen for auth state changes - _supabaseAuthSubscription = - sp.Supabase.instance.client.auth.onAuthStateChange - .listen((data) async { + _supabaseAuthSubscription = sp + .Supabase.instance.client.auth.onAuthStateChange + .listen((data) async { final event = data.event; final session = data.session; // Handle auth state changes if needed @@ -150,8 +151,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { final user = session.user; // Skip if already logged in as this user - final current = - await _subscriberService.getCurrentSubscriber(); + final current = await _subscriberService.getCurrentSubscriber(); if (current != null && current.externalId == user.id) { PushFireLogger.info( 'Subscriber already logged in as ${user.id}, skipping auto-login'); @@ -167,10 +167,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { ? null : user.userMetadata?['full_name']; loginSubscriber( - externalId: user.id, - email: email, - phone: phone, - name: name); + externalId: user.id, email: email, phone: phone, name: name); } else if (event == sp.AuthChangeEvent.signedOut) { logoutSubscriber(); } @@ -184,8 +181,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { PushFireLogger.info('User signed in: ${user.uid}'); // Skip if already logged in as this user - final current = - await _subscriberService.getCurrentSubscriber(); + final current = await _subscriberService.getCurrentSubscriber(); if (current != null && current.externalId == user.uid) { PushFireLogger.info( 'Subscriber already logged in as ${user.uid}, skipping auto-login'); @@ -201,10 +197,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { ? null : user.phoneNumber; loginSubscriber( - externalId: user.uid, - email: email, - name: name, - phone: phone); + externalId: user.uid, email: email, name: name, phone: phone); } else { PushFireLogger.info('User signed out'); logoutSubscriber(); @@ -226,7 +219,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { _deviceRegisteredController.add(_currentDevice!); PushFireLogger.info('Device auto-registration completed'); } catch (e) { - PushFireLogger.error('Device auto-registration failed', e); + PushFireLogger.warning('Device auto-registration failed', e); // Don't throw here - allow SDK to continue working } } @@ -249,7 +242,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { PushFireLogger.info('Device updated with new FCM token'); } catch (e) { - PushFireLogger.error('Failed to update device with new FCM token', e); + PushFireLogger.warning('Failed to update device with new FCM token', e); } }); } @@ -301,7 +294,7 @@ class PushFireSDKImpl with WidgetsBindingObserver { PushFireLogger.info('Device updated after permission status change'); } } catch (e) { - PushFireLogger.error( + PushFireLogger.warning( 'Failed to check permission status on app resume', e); } finally { _isCheckingPermission = false; @@ -318,21 +311,16 @@ class PushFireSDKImpl with WidgetsBindingObserver { }) async { _ensureInitialized(); - try { - _currentSubscriber = await _subscriberService.loginSubscriber( - externalId: externalId, - name: name, - email: email, - phone: phone, - metadata: metadata, - ); + _currentSubscriber = await _subscriberService.loginSubscriber( + externalId: externalId, + name: name, + email: email, + phone: phone, + metadata: metadata, + ); - _subscriberLoggedInController.add(_currentSubscriber!); - return _currentSubscriber!; - } catch (e) { - PushFireLogger.error('Subscriber login failed', e); - rethrow; - } + _subscriberLoggedInController.add(_currentSubscriber!); + return _currentSubscriber!; } /// Update subscriber @@ -349,33 +337,28 @@ class PushFireSDKImpl with WidgetsBindingObserver { throw const PushFireSubscriberException('No subscriber logged in'); } - try { - // Always use current subscriber's externalId (backend doesn't allow updates) - await _subscriberService.updateSubscriber( - subscriberId: currentSubscriber!.id!, - externalId: currentSubscriber.externalId, - name: name, - email: email, - phone: phone, - metadata: metadata, - ); + // Always use current subscriber's externalId (backend doesn't allow updates) + await _subscriberService.updateSubscriber( + subscriberId: currentSubscriber!.id!, + externalId: currentSubscriber.externalId, + name: name, + email: email, + phone: phone, + metadata: metadata, + ); - // Update the local subscriber state (externalId remains unchanged) - _currentSubscriber = currentSubscriber.copyWith( - name: name, - email: email, - phone: phone, - metadata: metadata, - ); + // Update the local subscriber state (externalId remains unchanged) + _currentSubscriber = currentSubscriber.copyWith( + name: name, + email: email, + phone: phone, + metadata: metadata, + ); - // Store updated subscriber data - await _subscriberService.storeSubscriberData(_currentSubscriber!); + // Store updated subscriber data + await _subscriberService.storeSubscriberData(_currentSubscriber!); - return _currentSubscriber!; - } catch (e) { - PushFireLogger.error('Subscriber update failed', e); - rethrow; - } + return _currentSubscriber!; } /// Logout subscriber @@ -384,11 +367,12 @@ class PushFireSDKImpl with WidgetsBindingObserver { try { await _subscriberService.logoutSubscriber(); + } finally { + // SubscriberService clears persisted local data even when the logout + // API call fails, so always mirror that cleared session in memory and + // emit the logout event. Any error still propagates to the caller. _currentSubscriber = null; _subscriberLoggedOutController.add(null); - } catch (e) { - PushFireLogger.error('Subscriber logout failed', e); - rethrow; } } @@ -576,26 +560,21 @@ class PushFireSDKImpl with WidgetsBindingObserver { Future reset() async { _ensureInitialized(); - try { - PushFireLogger.info('Resetting SDK'); + PushFireLogger.info('Resetting SDK'); - // Logout subscriber if logged in - if (await isSubscriberLoggedIn()) { - await logoutSubscriber(); - } + // Logout subscriber if logged in + if (await isSubscriberLoggedIn()) { + await logoutSubscriber(); + } - // Clear device data - await _deviceService.clearDeviceData(); + // Clear device data + await _deviceService.clearDeviceData(); - // Reset current state - _currentDevice = null; - _currentSubscriber = null; + // Reset current state + _currentDevice = null; + _currentSubscriber = null; - PushFireLogger.info('SDK reset completed'); - } catch (e) { - PushFireLogger.error('SDK reset failed', e); - rethrow; - } + PushFireLogger.info('SDK reset completed'); } /// Dispose SDK resources diff --git a/lib/src/services/device_service.dart b/lib/src/services/device_service.dart index 4934617..40ce481 100644 --- a/lib/src/services/device_service.dart +++ b/lib/src/services/device_service.dart @@ -31,7 +31,9 @@ class DeviceService { @visibleForTesting final Future Function()? getFcmTokenOverride; - DeviceService(this._apiClient, this._config, { + DeviceService( + this._apiClient, + this._config, { this.isPushNotificationEnabledOverride, this.getDeviceInfoOverride, this.getFcmTokenOverride, @@ -115,11 +117,10 @@ class DeviceService { PushFireLogger.info( 'Device registration completed: ${registeredDevice.id}'); return registeredDevice; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Device registration failed', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error during device registration', e); throw PushFireDeviceException('Device registration failed: $e', originalError: e); } @@ -148,10 +149,10 @@ class DeviceService { } return device.copyWith(id: deviceId); + } on PushFireException { + rethrow; } catch (e) { - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error registering new device', e); throw PushFireDeviceException('Failed to register device: $e', originalError: e); } @@ -163,10 +164,10 @@ class DeviceService { final deviceData = {'data': device.toJson()}; await _apiClient.patch('update-device', deviceData); return device; + } on PushFireException { + rethrow; } catch (e) { - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error updating device', e); throw PushFireDeviceException('Failed to update device: $e', originalError: e); } @@ -395,9 +396,11 @@ class DeviceService { } return isGranted; + } on PushFireException { + rethrow; } catch (e) { PushFireLogger.error( - 'Failed to request notification permission manually', e); + 'Unexpected error requesting notification permission', e); throw PushFireDeviceException( 'Failed to request notification permission: $e', originalError: e); @@ -470,13 +473,12 @@ class DeviceService { PushFireLogger.info('Notification preference updated to $enabled'); return SetNotificationResult.success; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Failed to set notification enabled', e); - if (e is PushFireException) { - rethrow; - } - throw PushFireDeviceException( - 'Failed to set notification preference: $e', + PushFireLogger.error( + 'Unexpected error setting notification preference', e); + throw PushFireDeviceException('Failed to set notification preference: $e', originalError: e); } } @@ -494,9 +496,10 @@ class DeviceService { isPermissionGranted: osPermission, isEnabled: savedPreference ?? true, ); + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Failed to get notification status', e); - if (e is PushFireException) rethrow; + PushFireLogger.error('Unexpected error getting notification status', e); throw PushFireDeviceException('Failed to get notification status: $e', originalError: e); } @@ -566,7 +569,7 @@ class DeviceService { } } } catch (e) { - PushFireLogger.error('Failed to check permission status change', e); + PushFireLogger.warning('Failed to check permission status change', e); return null; } } diff --git a/lib/src/services/subscriber_service.dart b/lib/src/services/subscriber_service.dart index c1fd602..c72fd33 100644 --- a/lib/src/services/subscriber_service.dart +++ b/lib/src/services/subscriber_service.dart @@ -86,11 +86,10 @@ class SubscriberService { PushFireLogger.info('Subscriber login completed: $subscriberId'); return subscriber; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Subscriber login failed', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error during subscriber login', e); throw PushFireSubscriberException( 'Subscriber login failed: $e', originalError: e, @@ -126,11 +125,10 @@ class SubscriberService { await _apiClient.patch('update-subscriber', updateData); PushFireLogger.info('Subscriber updated successfully'); + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Subscriber update failed', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error during subscriber update', e); throw PushFireSubscriberException( 'Subscriber update failed: $e', originalError: e, @@ -168,13 +166,16 @@ class SubscriberService { PushFireLogger.info('Subscriber logout completed'); } catch (e) { - PushFireLogger.error('Subscriber logout failed', e); - // Clear local data even if API call fails + // Single catch (not `on PushFireException { rethrow; }` like the other + // methods) is deliberate: local data must be cleared on BOTH expected + // and unexpected failures. Clear first, then rethrow PushFireExceptions + // (already logged downstream) or wrap-and-log genuinely unexpected ones. await _clearSubscriberData(); if (e is PushFireException) { rethrow; } + PushFireLogger.error('Unexpected error during subscriber logout', e); throw PushFireSubscriberException( 'Subscriber logout failed: $e', originalError: e, diff --git a/lib/src/services/tag_service.dart b/lib/src/services/tag_service.dart index 64bb315..c2ad734 100644 --- a/lib/src/services/tag_service.dart +++ b/lib/src/services/tag_service.dart @@ -45,11 +45,10 @@ class TagService { PushFireLogger.info('Tag added successfully: $tagId'); return tag; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Failed to add tag', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error adding tag', e); throw PushFireTagException( 'Failed to add tag: $e', originalError: e, @@ -91,11 +90,10 @@ class TagService { PushFireLogger.info('Tag updated successfully: $tagId'); return tag; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Failed to update tag', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error updating tag', e); throw PushFireTagException( 'Failed to update tag: $e', originalError: e, @@ -128,11 +126,10 @@ class TagService { await _apiClient.delete('remove-subscriber-tag', tagData); PushFireLogger.info('Tag removed successfully: $tagId'); + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Failed to remove tag', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error removing tag', e); throw PushFireTagException( 'Failed to remove tag: $e', originalError: e, diff --git a/lib/src/services/workflow_service.dart b/lib/src/services/workflow_service.dart index 1c4e80d..456f125 100644 --- a/lib/src/services/workflow_service.dart +++ b/lib/src/services/workflow_service.dart @@ -32,11 +32,10 @@ class WorkflowService { PushFireLogger.info('Workflow execution created successfully'); return response; + } on PushFireException { + rethrow; } catch (e) { - PushFireLogger.error('Workflow execution creation failed', e); - if (e is PushFireException) { - rethrow; - } + PushFireLogger.error('Unexpected error creating workflow execution', e); throw PushFireApiException( 'Workflow execution creation failed: $e', originalError: e, diff --git a/lib/src/utils/logger.dart b/lib/src/utils/logger.dart index b77a12b..ed1a1bc 100644 --- a/lib/src/utils/logger.dart +++ b/lib/src/utils/logger.dart @@ -1,4 +1,5 @@ import 'dart:developer' as developer; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:logging/logging.dart'; /// Centralized logging utility for PushFire SDK @@ -84,6 +85,35 @@ class PushFireLogger { } } + /// Build the API error log line. Extracted as a pure function for testing. + @visibleForTesting + static String formatApiError( + String method, + String endpoint, + int statusCode, + String? code, + String message, + ) { + final buffer = + StringBuffer('API error: $method $endpoint -> HTTP $statusCode'); + if (code != null) buffer.write(' code=$code'); + buffer.write(' msg="$message"'); + return buffer.toString(); + } + + /// Log an API error response as a single, clear error line + static void logApiError( + String method, + String endpoint, + int statusCode, + String? code, + String message, + ) { + if (_enableLogging) { + error(formatApiError(method, endpoint, statusCode, code, message)); + } + } + /// Log device information static void logDeviceInfo(Map deviceInfo) { if (_enableLogging) { diff --git a/pubspec.yaml b/pubspec.yaml index 64d7135..619e049 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A lightweight push notification tracking SDK for Firebase. homepage: https://github.com/FlywheelStudio/pushfire_sdk/blob/main/README.md repository: https://github.com/FlywheelStudio/pushfire_sdk -version: 0.1.11 +version: 0.2.0 environment: sdk: '>=3.3.1 <4.0.0' diff --git a/test/api/pushfire_api_client_test.dart b/test/api/pushfire_api_client_test.dart new file mode 100644 index 0000000..b192816 --- /dev/null +++ b/test/api/pushfire_api_client_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pushfire_sdk/src/api/pushfire_api_client.dart'; +import 'package:pushfire_sdk/src/config/pushfire_config.dart'; +import 'package:pushfire_sdk/src/exceptions/pushfire_exceptions.dart'; + +void main() { + const config = PushFireConfig( + apiKey: 'test-key', + baseUrl: 'https://api.pushfire.app/functions/v1/', + ); + + group('PushFireApiClient success responses', () { + test('2xx with JSON body returns decoded map', () async { + final mock = + MockClient((req) async => http.Response('{"id":"sub_1"}', 200)); + final client = PushFireApiClient(config, httpClient: mock); + final result = await client.post('login-subscriber', {'data': {}}); + expect(result['id'], 'sub_1'); + }); + + test('2xx with empty body returns success map', () async { + final mock = MockClient((req) async => http.Response('', 200)); + final client = PushFireApiClient(config, httpClient: mock); + final result = await client.post('logout-subscriber', {'data': {}}); + expect(result['success'], true); + }); + }); + + group('PushFireApiClient error responses', () { + test('4xx with JSON error preserves status, code, message, body', () async { + final mock = MockClient((req) async => http.Response( + '{"message":"Missing authorization header","code":"missing_auth"}', + 401, + )); + final client = PushFireApiClient(config, httpClient: mock); + + await expectLater( + client.patch('update-subscriber', {'data': {}}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.code, 'code', 'missing_auth') + .having((e) => e.message, 'message', 'Missing authorization header') + .having((e) => e.responseBody, 'responseBody', + contains('Missing authorization header'))), + ); + }); + + test('error is NOT re-wrapped as "Unexpected error"', () async { + final mock = + MockClient((req) async => http.Response('{"message":"Nope"}', 403)); + final client = PushFireApiClient(config, httpClient: mock); + + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.message, 'message', 'Nope') + .having((e) => e.statusCode, 'statusCode', 403)), + ); + }); + + test('5xx sets the status code', () async { + final mock = + MockClient((req) async => http.Response('{"message":"boom"}', 500)); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.message, 'message', 'boom') + .having((e) => e.code, 'code', isNull)), + ); + }); + + test('non-JSON error body falls back to a status message', () async { + final mock = + MockClient((req) async => http.Response('502', 502)); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 502) + .having((e) => e.message, 'message', contains('502')) + .having((e) => e.responseBody, 'responseBody', + contains('502'))), + ); + }); + }); + + group('PushFireApiClient transport errors', () { + test('SocketException becomes PushFireNetworkException', () async { + final mock = + MockClient((req) async => throw const SocketException('no route')); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.message, 'message', contains('Network error'))), + ); + }); + + test('TimeoutException becomes PushFireNetworkException', () async { + final mock = MockClient((req) async => throw TimeoutException('slow')); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.message, 'message', contains('timed out')) + .having((e) => e.originalError, 'originalError', + isA())), + ); + }); + + test('timeout message includes the configured seconds', () async { + final mock = MockClient((req) async => throw TimeoutException('slow')); + final client = PushFireApiClient( + const PushFireConfig( + apiKey: 'k', + baseUrl: 'https://api.pushfire.app/functions/v1/', + timeoutSeconds: 15), + httpClient: mock, + ); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.message, 'message', contains('15s'))), + ); + }); + }); +} diff --git a/test/config/pushfire_config_test.dart b/test/config/pushfire_config_test.dart index e5d6829..19b4be5 100644 --- a/test/config/pushfire_config_test.dart +++ b/test/config/pushfire_config_test.dart @@ -44,10 +44,10 @@ void main() { expect(config.apiKey, 'test-api-key'); }); - test('baseUrl defaults to the expected Supabase URL', () { + test('baseUrl defaults to the expected PushFire API URL', () { expect( config.baseUrl, - 'https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/', + 'https://api.pushfire.app/functions/v1/', ); }); @@ -258,7 +258,7 @@ void main() { expect( result, 'PushFireConfig(baseUrl: ' - 'https://jojnoebcqoqjlshwzmjm.supabase.co/functions/v1/, ' + 'https://api.pushfire.app/functions/v1/, ' 'enableLogging: false, timeoutSeconds: 30)', ); }); diff --git a/test/exceptions/pushfire_exceptions_test.dart b/test/exceptions/pushfire_exceptions_test.dart index 2b92bf4..224bf87 100644 --- a/test/exceptions/pushfire_exceptions_test.dart +++ b/test/exceptions/pushfire_exceptions_test.dart @@ -41,7 +41,8 @@ void main() { group('toString', () { test('with message only', () { const exception = PushFireApiException('Something went wrong'); - expect(exception.toString(), 'PushFireApiException: Something went wrong'); + expect( + exception.toString(), 'PushFireApiException: Something went wrong'); }); test('with code only', () { diff --git a/test/models/subscriber_test.dart b/test/models/subscriber_test.dart index 67e22e0..232131a 100644 --- a/test/models/subscriber_test.dart +++ b/test/models/subscriber_test.dart @@ -82,8 +82,7 @@ void main() { ); expect( (nestedMetadataSubscriber.metadata!['preferences'] - as Map)['notifications'] - as Map, + as Map)['notifications'] as Map, {'email': true, 'sms': false}, ); }); @@ -158,8 +157,7 @@ void main() { final subscriber = Subscriber.fromJson(json); expect(subscriber.metadata, isNotNull); - final profile = - subscriber.metadata!['profile'] as Map; + final profile = subscriber.metadata!['profile'] as Map; expect(profile['bio'], 'Hello world'); expect(profile['links'], ['https://a.com', 'https://b.com']); expect(subscriber.metadata!['scores'], [100, 200, 300]); @@ -397,14 +395,20 @@ void main() { final a = Subscriber( externalId: 'ext-deep', metadata: { - 'nested': {'inner': 'value', 'list': [1, 2, 3]}, + 'nested': { + 'inner': 'value', + 'list': [1, 2, 3] + }, 'top': 'level', }, ); final b = Subscriber( externalId: 'ext-deep', metadata: { - 'nested': {'inner': 'value', 'list': [1, 2, 3]}, + 'nested': { + 'inner': 'value', + 'list': [1, 2, 3] + }, 'top': 'level', }, ); @@ -516,8 +520,7 @@ void main() { expect(a.hashCode, equals(b.hashCode)); }); - test( - 'metadata with different insertion order produces same hashCode', () { + test('metadata with different insertion order produces same hashCode', () { final metaA = {'x': 1, 'y': 2, 'z': 3}; final metaB = {}; diff --git a/test/models/workflow_execution_test.dart b/test/models/workflow_execution_test.dart index c8f1428..a4a6b95 100644 --- a/test/models/workflow_execution_test.dart +++ b/test/models/workflow_execution_test.dart @@ -433,7 +433,8 @@ void main() { expect(request.target, target); }); - test('scheduledFor is optional even for scheduled type at construction', () { + test('scheduledFor is optional even for scheduled type at construction', + () { // Construction does not validate; validate() does. final request = WorkflowExecutionRequest( workflowId: validUuid1, @@ -582,8 +583,8 @@ void main() { ); final json = request.toJson(); - final targetJson = - (json['data'] as Map)['target'] as Map; + final targetJson = (json['data'] as Map)['target'] + as Map; expect(targetJson['type'], 'Segments'); expect(targetJson['values'], [validUuid2, validUuid3]); @@ -850,7 +851,8 @@ void main() { () => request.validate(), throwsA( predicate( - (e) => e.message == + (e) => + e.message == 'All target values must be valid UUIDs: $badValue', ), ), @@ -859,7 +861,8 @@ void main() { }); group('validation priority order', () { - test('empty workflowId checked before scheduled without scheduledFor', () { + test('empty workflowId checked before scheduled without scheduledFor', + () { final request = WorkflowExecutionRequest( workflowId: '', type: WorkflowExecutionType.scheduled, @@ -876,7 +879,9 @@ void main() { ); }); - test('scheduled without scheduledFor checked before empty target values', () { + test( + 'scheduled without scheduledFor checked before empty target values', + () { final request = WorkflowExecutionRequest( workflowId: validUuid1, type: WorkflowExecutionType.scheduled, @@ -915,7 +920,9 @@ void main() { ); }); - test('invalid workflowId UUID checked before invalid target value UUIDs', () { + test( + 'invalid workflowId UUID checked before invalid target value UUIDs', + () { final request = WorkflowExecutionRequest( workflowId: 'not-uuid-format', type: WorkflowExecutionType.immediate, diff --git a/test/services/device_service_notification_preference_test.dart b/test/services/device_service_notification_preference_test.dart index 2bad202..2d790f7 100644 --- a/test/services/device_service_notification_preference_test.dart +++ b/test/services/device_service_notification_preference_test.dart @@ -212,10 +212,8 @@ void main() { final status = await service.getNotificationStatus(); - expect( - status, - const NotificationStatus( - isPermissionGranted: true, isEnabled: true)); + expect(status, + const NotificationStatus(isPermissionGranted: true, isEnabled: true)); }); test('returns OS granted + preference false', () async { @@ -250,10 +248,8 @@ void main() { final status = await service.getNotificationStatus(); - expect( - status, - const NotificationStatus( - isPermissionGranted: true, isEnabled: true)); + expect(status, + const NotificationStatus(isPermissionGranted: true, isEnabled: true)); }); test('works without device registration', () async { diff --git a/test/services/subscriber_service_test.dart b/test/services/subscriber_service_test.dart new file mode 100644 index 0000000..3ec33e0 --- /dev/null +++ b/test/services/subscriber_service_test.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pushfire_sdk/src/api/pushfire_api_client.dart'; +import 'package:pushfire_sdk/src/config/pushfire_config.dart'; +import 'package:pushfire_sdk/src/exceptions/pushfire_exceptions.dart'; +import 'package:pushfire_sdk/src/models/subscriber.dart'; +import 'package:pushfire_sdk/src/services/device_service.dart'; +import 'package:pushfire_sdk/src/services/subscriber_service.dart'; + +/// Fake API client that allows configuring what post() throws or returns +class FakeApiClient extends PushFireApiClient { + Object? _postError; + Map _postResponse = {'success': true}; + + FakeApiClient() + : super(const PushFireConfig(apiKey: 'k', baseUrl: 'http://test/')); + + void throwOnPost(Object error) => _postError = error; + void returnOnPost(Map response) => _postResponse = response; + + @override + Future> post( + String endpoint, Map data) async { + if (_postError != null) throw _postError!; + return _postResponse; + } + + @override + Future> patch( + String endpoint, Map data) async { + return {'success': true}; + } +} + +/// Shared preferences keys (mirrors SubscriberService constants) +const _subscriberIdKey = 'pushfire_subscriber_id'; +const _subscriberDataKey = 'pushfire_subscriber_data'; +const _deviceIdKey = 'pushfire_device_id'; + +/// Seed SharedPreferences with a subscriber and a device id +void _seedPrefs() { + const subscriber = Subscriber( + id: 'sub-123', + deviceId: 'dev-abc', + externalId: 'user-1', + ); + SharedPreferences.setMockInitialValues({ + _subscriberIdKey: 'sub-123', + _subscriberDataKey: json.encode(subscriber.toJson()), + _deviceIdKey: 'dev-abc', + }); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SubscriberService.logoutSubscriber - clears local data on error', () { + late FakeApiClient fakeApi; + late SubscriberService service; + + setUp(() { + fakeApi = FakeApiClient(); + final deviceService = DeviceService( + fakeApi, + const PushFireConfig(apiKey: 'k'), + ); + service = SubscriberService(fakeApi, deviceService); + }); + + test('clears local data when the API throws a PushFireApiException', + () async { + _seedPrefs(); + + fakeApi.throwOnPost( + const PushFireApiException('Unauthorized', statusCode: 401), + ); + + await expectLater( + service.logoutSubscriber(), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 401)), + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(_subscriberIdKey), isNull, + reason: 'subscriber-id key should be cleared after logout error'); + expect(prefs.getString(_subscriberDataKey), isNull, + reason: 'subscriber-data key should be cleared after logout error'); + }); + + test('clears local data when the API throws an unexpected error', () async { + _seedPrefs(); + + fakeApi.throwOnPost(Exception('network blip')); + + await expectLater( + service.logoutSubscriber(), + throwsA(isA()), + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(_subscriberIdKey), isNull, + reason: 'subscriber-id key should be cleared after unexpected error'); + expect(prefs.getString(_subscriberDataKey), isNull, + reason: + 'subscriber-data key should be cleared after unexpected error'); + }); + }); +} diff --git a/test/utils/logger_test.dart b/test/utils/logger_test.dart new file mode 100644 index 0000000..9136873 --- /dev/null +++ b/test/utils/logger_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pushfire_sdk/src/utils/logger.dart'; + +void main() { + group('PushFireLogger.formatApiError', () { + test('includes code when present', () { + expect( + PushFireLogger.formatApiError('PATCH', 'update-subscriber', 401, + 'missing_auth', 'Missing authorization header'), + 'API error: PATCH update-subscriber -> HTTP 401 ' + 'code=missing_auth msg="Missing authorization header"', + ); + }); + + test('omits code when null', () { + expect( + PushFireLogger.formatApiError('POST', 'x', 500, null, 'boom'), + 'API error: POST x -> HTTP 500 msg="boom"', + ); + }); + }); +}