From 754ed3044ea6b0592718fccfd0d62f9bb8ad1bfb Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Thu, 28 May 2026 15:06:24 -0700 Subject: [PATCH] Api testing branch --- .../services/device_registration_service.dart | 177 ++++++++++++-- .../device_registration_provider.dart | 13 ++ .../device_registration_service_test.dart | 217 ++++++++++++++++-- 3 files changed, 374 insertions(+), 33 deletions(-) diff --git a/lib/features/scanner/data/services/device_registration_service.dart b/lib/features/scanner/data/services/device_registration_service.dart index 5ccc6da..6efaf1e 100644 --- a/lib/features/scanner/data/services/device_registration_service.dart +++ b/lib/features/scanner/data/services/device_registration_service.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/services/secure_http_client.dart'; import 'package:rgnets_fdk/core/services/websocket_service.dart'; +import 'package:rgnets_fdk/core/utils/log_redaction.dart' as log_redaction; import 'package:rgnets_fdk/features/scanner/domain/entities/scan_session.dart'; /// Outcome of a device-registration round-trip with the backend. @@ -27,14 +31,28 @@ class RegistrationServiceOutcome { final Map? data; } -/// Service for registering devices via WebSocket. +/// Service for registering devices. +/// +/// Access Points register over REST (`POST /api/access_points/ +/// register_ap_device`); ONTs and switches register over WebSocket. The AP +/// path was moved off WebSocket because the rXg's controller-side work can +/// outlive AnyCable's gRPC deadline, leaving the client without an +/// authoritative reply. class DeviceRegistrationService { - DeviceRegistrationService({required WebSocketService webSocketService}) - : _wsService = webSocketService; + DeviceRegistrationService({ + required WebSocketService webSocketService, + http.Client? httpClient, + }) : _wsService = webSocketService, + _httpClient = httpClient; static const String _tag = 'DeviceRegistration'; final WebSocketService _wsService; + final http.Client? _httpClient; + + /// Lazily falls back to the shared cert-validating client so self-signed + /// rXg certificates are accepted in debug builds. + http.Client get _client => _httpClient ?? SecureHttpClient.getClient(); /// Check if WebSocket is connected. bool get isConnected => _wsService.isConnected; @@ -52,8 +70,23 @@ class DeviceRegistrationService { String? partNumber, String? model, int? existingDeviceId, + String? siteUrl, + String? apiKey, Duration timeout = const Duration(seconds: 30), }) async { + // Access Points register over REST, not WebSocket. + if (deviceType == DeviceType.accessPoint) { + return _registerApViaRest( + mac: mac, + serialNumber: serialNumber, + pmsRoomId: pmsRoomId, + existingDeviceId: existingDeviceId, + siteUrl: siteUrl, + apiKey: apiKey, + timeout: timeout, + ); + } + if (!_wsService.isConnected) { LoggerService.error( 'Cannot register device: WebSocket not connected', @@ -111,6 +144,131 @@ class DeviceRegistrationService { } } + /// Registers an Access Point via the rXg's `register_ap_device` REST + /// collection action. Returns the rXg's authoritative verdict. + /// + /// The endpoint mirrors the WebSocket `register_ap_device` crud action: + /// it associates the scanned MAC/serial with a PMS room, creating a new AP + /// or updating the existing one identified by [existingDeviceId]. + Future _registerApViaRest({ + required String mac, + required String serialNumber, + required int pmsRoomId, + required Duration timeout, + int? existingDeviceId, + String? siteUrl, + String? apiKey, + }) async { + if (siteUrl == null || siteUrl.isEmpty || apiKey == null || apiKey.isEmpty) { + LoggerService.error( + 'Cannot register AP via REST: missing site URL or API key', + tag: _tag, + ); + return const RegistrationServiceOutcome.failure( + errorMessage: 'Not signed in', + status: 0, + ); + } + + final uri = Uri.parse( + 'https://${_normalizeSiteUrl(siteUrl)}' + '/api/access_points/register_ap_device.json?api_key=$apiKey', + ); + final body = { + 'serial_number': serialNumber, + 'mac': mac, + 'pms_room_id': pmsRoomId, + if (existingDeviceId != null) 'ap_id': existingDeviceId, + }; + + LoggerService.info( + 'Registering AccessPoint via REST POST ' + '${log_redaction.scrubUrlForLog(uri)}', + tag: _tag, + ); + + try { + final response = await _client + .post( + uri, + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode(body), + ) + .timeout(timeout); + return _parseRestResponse(response); + } on TimeoutException { + LoggerService.warning( + 'AP REST registration timed out waiting for backend response', + tag: _tag, + ); + return const RegistrationServiceOutcome.failure( + errorMessage: 'Timed out waiting for backend', + status: 0, + ); + } on Object catch (e) { + // ClientException / TimeoutException can embed the full URL (with + // api_key); scrub before logging and never pass the raw object. + final scrubbed = log_redaction.scrubErrorForLog(e); + LoggerService.error( + 'AP REST registration failed: $scrubbed', + tag: _tag, + ); + return RegistrationServiceOutcome.failure( + errorMessage: scrubbed, + status: 0, + ); + } + } + + /// Parses the rXg's `register_ap_device` REST response. The controller + /// renders the `api:` hash directly as the JSON body (e.g. + /// `{"message": "...", "access_point": {...}}`) with the HTTP status + /// carrying the verdict — 200/201 on success, 4xx on failure. + RegistrationServiceOutcome _parseRestResponse(http.Response response) { + final status = response.statusCode; + Map? body; + if (response.body.isNotEmpty) { + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + body = decoded; + } + } on FormatException { + body = null; + } + } + + if (status >= 200 && status < 300) { + return RegistrationServiceOutcome.success(data: body, status: status); + } + + final message = body?['message']; + return RegistrationServiceOutcome.failure( + errorMessage: message is String && message.isNotEmpty + ? message + : 'Registration failed (status $status)', + status: status, + ); + } + + /// Strips scheme and trailing slash so a stored `siteUrl` (which may be a + /// bare host or a full `https://host/`) yields a clean authority. + static String _normalizeSiteUrl(String url) { + var normalized = url; + if (normalized.startsWith('https://')) { + normalized = normalized.substring(8); + } else if (normalized.startsWith('http://')) { + normalized = normalized.substring(7); + } + if (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; + } + /// Parses a [SocketMessage] returned by `requestActionCable` for a /// registration into a [RegistrationServiceOutcome]. /// @@ -176,16 +334,9 @@ class DeviceRegistrationService { }) { switch (deviceType) { case DeviceType.accessPoint: - return ( - resourceType: 'access_points', - additionalData: { - 'crud_action': 'register_ap_device', - 'mac': mac, - 'serial_number': serialNumber, - 'pms_room_id': pmsRoomId, - if (existingDeviceId != null) 'ap_id': existingDeviceId, - }, - ); + // Access Points register over REST via _registerApViaRest and never + // reach this WebSocket payload builder. + throw ArgumentError('Access Points register over REST, not WebSocket'); case DeviceType.ont: // The dedicated register_ont_device extra collection action diff --git a/lib/features/scanner/presentation/providers/device_registration_provider.dart b/lib/features/scanner/presentation/providers/device_registration_provider.dart index 228f9c6..ad57b01 100644 --- a/lib/features/scanner/presentation/providers/device_registration_provider.dart +++ b/lib/features/scanner/presentation/providers/device_registration_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rgnets_fdk/core/providers/core_providers.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/providers/websocket_sync_providers.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; @@ -673,6 +674,16 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { // correlated by request_id and we learn the real outcome instead of // assuming success on send. final registrationService = ref.read(deviceRegistrationServiceProvider); + + // Access Points register over REST, which needs the live site URL and + // API token; ONTs/switches still go over the authenticated WebSocket. + String? siteUrl; + String? apiKey; + if (deviceType == DeviceType.accessPoint) { + siteUrl = ref.read(storageServiceProvider).siteUrl; + apiKey = await ref.read(secureStorageServiceProvider).getToken(); + } + final outcome = await registrationService.registerDevice( deviceType: deviceType, mac: mac, @@ -681,6 +692,8 @@ class DeviceRegistrationNotifier extends _$DeviceRegistrationNotifier { partNumber: partNumber, model: model, existingDeviceId: existingDeviceId, + siteUrl: siteUrl, + apiKey: apiKey, ); if (!outcome.success) { diff --git a/test/features/scanner/data/services/device_registration_service_test.dart b/test/features/scanner/data/services/device_registration_service_test.dart index 490e6aa..350b655 100644 --- a/test/features/scanner/data/services/device_registration_service_test.dart +++ b/test/features/scanner/data/services/device_registration_service_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; import 'package:rgnets_fdk/core/services/websocket_service.dart'; import 'package:rgnets_fdk/features/scanner/data/services/device_registration_service.dart'; @@ -8,20 +10,28 @@ import 'package:rgnets_fdk/features/scanner/domain/entities/scan_session.dart'; class _MockWebSocketService extends Mock implements WebSocketService {} +class _MockHttpClient extends Mock implements http.Client {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { // mocktail requires a fallback for non-primitive types used with any(). registerFallbackValue(const Duration(seconds: 1)); + registerFallbackValue(Uri.parse('https://example.test')); }); late _MockWebSocketService ws; + late _MockHttpClient httpClient; late DeviceRegistrationService service; setUp(() { ws = _MockWebSocketService(); - service = DeviceRegistrationService(webSocketService: ws); + httpClient = _MockHttpClient(); + service = DeviceRegistrationService( + webSocketService: ws, + httpClient: httpClient, + ); }); group('DeviceRegistrationService.registerDevice', () { @@ -133,13 +143,13 @@ void main() { payload: { 'action': 'resource_response', 'status': 404, - 'data': {'message': 'Existing AP not found for id 2'}, + 'data': {'message': 'Existing ONT not found for id 2'}, }, ), ); final outcome = await service.registerDevice( - deviceType: DeviceType.accessPoint, + deviceType: DeviceType.ont, mac: '00E63A2D2570', serialNumber: '292372002215', pmsRoomId: 2, @@ -147,7 +157,7 @@ void main() { ); expect(outcome.success, isFalse); - expect(outcome.errorMessage, 'Existing AP not found for id 2'); + expect(outcome.errorMessage, 'Existing ONT not found for id 2'); expect(outcome.status, 404); }, ); @@ -308,7 +318,7 @@ void main() { expect(outcome.status, 0); }); - test('sends the correct resource_type and crud_action per device type', () async { + test('sends the correct resource_type and crud_action per WS device type', () async { when(() => ws.isConnected).thenReturn(true); when( () => ws.requestActionCable( @@ -324,18 +334,14 @@ void main() { ), ); + // Access Points register over REST, not WebSocket, so only ONT and + // switch flow through requestActionCable. await service.registerDevice( deviceType: DeviceType.ont, mac: 'aa', serialNumber: 's', pmsRoomId: 1, ); - await service.registerDevice( - deviceType: DeviceType.accessPoint, - mac: 'bb', - serialNumber: 's', - pmsRoomId: 1, - ); await service.registerDevice( deviceType: DeviceType.switchDevice, mac: 'cc', @@ -352,23 +358,19 @@ void main() { ), ).captured; - // Each call captures two values (resourceType, additionalData) → 6 total. - expect(calls, hasLength(6)); + // Each call captures two values (resourceType, additionalData) → 4 total. + expect(calls, hasLength(4)); // ONT expect(calls[0], 'media_converters'); expect((calls[1] as Map)['crud_action'], 'register_ont_device'); - // AP - expect(calls[2], 'access_points'); - expect((calls[3] as Map)['crud_action'], 'register_ap_device'); - // Switch - expect(calls[4], 'switch_devices'); - expect((calls[5] as Map)['crud_action'], 'register_switch_device'); + expect(calls[2], 'switch_devices'); + expect((calls[3] as Map)['crud_action'], 'register_switch_device'); // None of the additionalData maps should contain reserved keys. - for (final additional in [calls[1], calls[3], calls[5]]) { + for (final additional in [calls[1], calls[3]]) { final map = additional as Map; expect(map.containsKey('action'), isFalse); expect(map.containsKey('resource_type'), isFalse); @@ -376,4 +378,179 @@ void main() { } }); }); + + group('DeviceRegistrationService.registerDevice (AP REST path)', () { + test('never touches the WebSocket for Access Points', () async { + when(() => ws.isConnected).thenReturn(true); + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer((_) async => http.Response('{"message":"ok"}', 200)); + + await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: 'bb', + serialNumber: 's', + pmsRoomId: 1, + siteUrl: 'rxg.example.test', + apiKey: 'secret', + ); + + verifyNever( + () => ws.requestActionCable( + action: any(named: 'action'), + resourceType: any(named: 'resourceType'), + additionalData: any(named: 'additionalData'), + timeout: any(named: 'timeout'), + ), + ); + }); + + test('POSTs to register_ap_device with the scanned fields', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response( + '{"message":"Updated existing AP PMS room.","access_point":{"id":7}}', + 200, + ), + ); + + final outcome = await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: '00E63A2D2570', + serialNumber: '292372002215', + pmsRoomId: 2, + existingDeviceId: 7, + siteUrl: 'https://rxg.example.test/', + apiKey: 'secret', + ); + + expect(outcome.success, isTrue); + expect(outcome.status, 200); + expect(outcome.data?['access_point'], isA>()); + + final captured = verify( + () => httpClient.post( + captureAny(), + headers: any(named: 'headers'), + body: captureAny(named: 'body'), + ), + ).captured; + final uri = captured[0] as Uri; + final body = jsonDecode(captured[1] as String) as Map; + + // Scheme + trailing slash normalized away from the stored siteUrl. + expect(uri.toString(), + 'https://rxg.example.test/api/access_points/register_ap_device.json?api_key=secret'); + expect(body['serial_number'], '292372002215'); + expect(body['mac'], '00E63A2D2570'); + expect(body['pms_room_id'], 2); + expect(body['ap_id'], 7); + }); + + test('treats HTTP 201 as success', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response('{"message":"Access Point created."}', 201), + ); + + final outcome = await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: 'bb', + serialNumber: 's', + pmsRoomId: 1, + siteUrl: 'rxg.example.test', + apiKey: 'secret', + ); + + expect(outcome.success, isTrue); + expect(outcome.status, 201); + }); + + test('surfaces the controller message on a 4xx', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response( + '{"message":"PMS Room not found for id 99"}', + 404, + ), + ); + + final outcome = await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: 'bb', + serialNumber: 's', + pmsRoomId: 99, + siteUrl: 'rxg.example.test', + apiKey: 'secret', + ); + + expect(outcome.success, isFalse); + expect(outcome.errorMessage, 'PMS Room not found for id 99'); + expect(outcome.status, 404); + }); + + test('fails without calling HTTP when credentials are missing', () async { + final outcome = await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: 'bb', + serialNumber: 's', + pmsRoomId: 1, + siteUrl: null, + apiKey: null, + ); + + expect(outcome.success, isFalse); + expect(outcome.errorMessage, 'Not signed in'); + expect(outcome.status, 0); + verifyNever( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ); + }); + + test('returns the timeout message on TimeoutException', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenThrow(TimeoutException('slow')); + + final outcome = await service.registerDevice( + deviceType: DeviceType.accessPoint, + mac: 'bb', + serialNumber: 's', + pmsRoomId: 1, + siteUrl: 'rxg.example.test', + apiKey: 'secret', + ); + + expect(outcome.success, isFalse); + expect(outcome.errorMessage, 'Timed out waiting for backend'); + expect(outcome.status, 0); + }); + }); }