From 2f6ba5a8ca554fa0c3ad23f2e26420e430fe77a8 Mon Sep 17 00:00:00 2001 From: Peter Jhong Date: Mon, 5 Jan 2026 13:49:08 +0800 Subject: [PATCH 1/3] refactor(wan_external): migrate JNAP logic to service layer - Create WanExternalService encapsulating JNAP communication - Create WanExternalUIModel to isolate JNAP model from provider layer - Refactor WANExternalNotifier to use service instead of direct JNAP calls - Update WANExternalState to use UI model - Update instant_verify_state.dart consumer to use UI model type - Add comprehensive unit tests for service, provider, state, and UI model (33 tests) - Add test data builder for WAN external tests --- .../jnap/models/wan_external_ui_model.dart | 77 ++++++++ .../jnap/providers/wan_external_provider.dart | 26 ++- .../jnap/providers/wan_external_state.dart | 8 +- .../jnap/services/wan_external_service.dart | 57 ++++++ .../providers/instant_verify_state.dart | 8 +- .../models/wan_external_ui_model_test.dart | 125 ++++++++++++ .../providers/wan_external_provider_test.dart | 181 ++++++++++++++++++ .../providers/wan_external_state_test.dart | 157 +++++++++++++++ .../services/wan_external_service_test.dart | 159 +++++++++++++++ .../test_data/wan_external_test_data.dart | 54 ++++++ 10 files changed, 829 insertions(+), 23 deletions(-) create mode 100644 lib/core/jnap/models/wan_external_ui_model.dart create mode 100644 lib/core/jnap/services/wan_external_service.dart create mode 100644 test/core/jnap/models/wan_external_ui_model_test.dart create mode 100644 test/core/jnap/providers/wan_external_provider_test.dart create mode 100644 test/core/jnap/providers/wan_external_state_test.dart create mode 100644 test/core/jnap/services/wan_external_service_test.dart create mode 100644 test/mocks/test_data/wan_external_test_data.dart diff --git a/lib/core/jnap/models/wan_external_ui_model.dart b/lib/core/jnap/models/wan_external_ui_model.dart new file mode 100644 index 000000000..b6d3fbaaa --- /dev/null +++ b/lib/core/jnap/models/wan_external_ui_model.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external.dart'; + +/// UI model for WAN external IP information. +/// +/// Isolates the presentation layer from JNAP data models. +/// Contains public and private WAN IPv4/IPv6 addresses. +class WanExternalUIModel extends Equatable { + final String? publicWanIPv4; + final String? publicWanIPv6; + final String? privateWanIPv4; + final String? privateWanIPv6; + + const WanExternalUIModel({ + this.publicWanIPv4, + this.publicWanIPv6, + this.privateWanIPv4, + this.privateWanIPv6, + }); + + /// Creates a UI model from JNAP WanExternal model. + factory WanExternalUIModel.fromJnap(WanExternal jnap) { + return WanExternalUIModel( + publicWanIPv4: jnap.publicWanIPv4, + publicWanIPv6: jnap.publicWanIPv6, + privateWanIPv4: jnap.privateWanIPv4, + privateWanIPv6: jnap.privateWanIPv6, + ); + } + + WanExternalUIModel copyWith({ + String? publicWanIPv4, + String? publicWanIPv6, + String? privateWanIPv4, + String? privateWanIPv6, + }) { + return WanExternalUIModel( + publicWanIPv4: publicWanIPv4 ?? this.publicWanIPv4, + publicWanIPv6: publicWanIPv6 ?? this.publicWanIPv6, + privateWanIPv4: privateWanIPv4 ?? this.privateWanIPv4, + privateWanIPv6: privateWanIPv6 ?? this.privateWanIPv6, + ); + } + + Map toMap() { + return { + 'publicWanIPv4': publicWanIPv4, + 'publicWanIPv6': publicWanIPv6, + 'privateWanIPv4': privateWanIPv4, + 'privateWanIPv6': privateWanIPv6, + }; + } + + factory WanExternalUIModel.fromMap(Map map) { + return WanExternalUIModel( + publicWanIPv4: map['publicWanIPv4'] as String?, + publicWanIPv6: map['publicWanIPv6'] as String?, + privateWanIPv4: map['privateWanIPv4'] as String?, + privateWanIPv6: map['privateWanIPv6'] as String?, + ); + } + + String toJson() => json.encode(toMap()); + + factory WanExternalUIModel.fromJson(String source) => + WanExternalUIModel.fromMap(json.decode(source) as Map); + + @override + List get props => [ + publicWanIPv4, + publicWanIPv6, + privateWanIPv4, + privateWanIPv6, + ]; +} diff --git a/lib/core/jnap/providers/wan_external_provider.dart b/lib/core/jnap/providers/wan_external_provider.dart index 4b4d12cc5..18e8ae70a 100644 --- a/lib/core/jnap/providers/wan_external_provider.dart +++ b/lib/core/jnap/providers/wan_external_provider.dart @@ -1,11 +1,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/wan_external_service.dart'; import 'package:privacy_gui/core/utils/logger.dart'; final wanExternalProvider = @@ -15,10 +13,10 @@ final wanExternalProvider = class WANExternalNotifier extends Notifier { @override WANExternalState build() { - return WANExternalState(); + return const WANExternalState(); } - FutureOr fetch({bool force = false}) { + FutureOr fetch({bool force = false}) async { if (!serviceHelper.isSupportWANExternal()) { return state; } @@ -26,20 +24,18 @@ class WANExternalNotifier extends Notifier { 3600 * 1000) { return state; } - final repo = ref.read(routerRepositoryProvider); - return repo - .send(JNAPAction.getWANExternal, fetchRemote: force, timeoutMs: 30000) - .then((result) { - final wanExternalData = WanExternal.fromMap(result.output); + + try { + final service = ref.read(wanExternalServiceProvider); + final wanExternalData = await service.fetchWanExternal(force: force); state = state.copyWith( wanExternal: wanExternalData, lastUpdate: DateTime.now().millisecondsSinceEpoch); return state; - }).onError((error, stackTrace) { - logger.d('[WanExternal]: error fetch wan external data!'); - return state; - }).whenComplete(() { + } catch (error) { + logger.d('[WanExternal]: error fetch wan external data: $error'); state = state.copyWith(lastUpdate: DateTime.now().millisecondsSinceEpoch); - }); + return state; + } } } diff --git a/lib/core/jnap/providers/wan_external_state.dart b/lib/core/jnap/providers/wan_external_state.dart index e1c59b2f1..3e22975a2 100644 --- a/lib/core/jnap/providers/wan_external_state.dart +++ b/lib/core/jnap/providers/wan_external_state.dart @@ -2,10 +2,10 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; class WANExternalState extends Equatable { - final WanExternal? wanExternal; + final WanExternalUIModel? wanExternal; final int lastUpdate; const WANExternalState({ @@ -14,7 +14,7 @@ class WANExternalState extends Equatable { }); WANExternalState copyWith({ - WanExternal? wanExternal, + WanExternalUIModel? wanExternal, int? lastUpdate, }) { return WANExternalState( @@ -33,7 +33,7 @@ class WANExternalState extends Equatable { factory WANExternalState.fromMap(Map map) { return WANExternalState( wanExternal: map['wanExternal'] != null - ? WanExternal.fromMap(map['wanExternal']) + ? WanExternalUIModel.fromMap(map['wanExternal']) : null, lastUpdate: map['lastUpdate']?.toInt() ?? 0, ); diff --git a/lib/core/jnap/services/wan_external_service.dart b/lib/core/jnap/services/wan_external_service.dart new file mode 100644 index 000000000..69b4823ca --- /dev/null +++ b/lib/core/jnap/services/wan_external_service.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; + +/// Riverpod provider for WanExternalService. +final wanExternalServiceProvider = Provider((ref) { + return WanExternalService(ref.watch(routerRepositoryProvider)); +}); + +/// Stateless service for WAN external data operations. +/// +/// Handles JNAP communication for retrieving WAN external IP information. +/// Transforms JNAP data models to UI models for the presentation layer. +class WanExternalService { + final RouterRepository _routerRepository; + + WanExternalService(this._routerRepository); + + /// Fetches WAN external IP information from router. + /// + /// [force] - If true, bypasses cache and fetches from device. + /// Default: false (may use cached data). + /// + /// Returns: [WanExternalUIModel] with public/private IPv4/IPv6 addresses. + /// + /// Throws: + /// - [UnauthorizedError] if authentication fails + /// - [UnexpectedError] for other JNAP errors + Future fetchWanExternal({bool force = false}) async { + try { + final result = await _routerRepository.send( + JNAPAction.getWANExternal, + fetchRemote: force, + timeoutMs: 30000, + ); + final wanExternal = WanExternal.fromMap(result.output); + final uiModel = WanExternalUIModel.fromJnap(wanExternal); + logger.d('[Service]:[WanExternal]: Fetched ${uiModel.toJson()}'); + return uiModel; + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } + + /// Maps JNAP errors to ServiceError types. + ServiceError _mapJnapError(JNAPError error) { + return switch (error.result) { + '_ErrorUnauthorized' => const UnauthorizedError(), + _ => UnexpectedError(originalError: error, message: error.result), + }; + } +} diff --git a/lib/page/instant_verify/providers/instant_verify_state.dart b/lib/page/instant_verify/providers/instant_verify_state.dart index 1d0aaafc5..b0da3c97c 100644 --- a/lib/page/instant_verify/providers/instant_verify_state.dart +++ b/lib/page/instant_verify/providers/instant_verify_state.dart @@ -5,14 +5,14 @@ import 'package:equatable/equatable.dart'; import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; import 'package:privacy_gui/core/jnap/models/radio_info.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; import 'package:privacy_gui/core/jnap/models/wan_status.dart'; class InstantVerifyState extends Equatable { final WANConnectionInfo? wanConnection; final GetRadioInfo radioInfo; final GuestRadioSettings guestRadioSettings; - final WanExternal? wanExternal; + final WanExternalUIModel? wanExternal; final bool isRunning; const InstantVerifyState({ @@ -27,7 +27,7 @@ class InstantVerifyState extends Equatable { WANConnectionInfo? wanConnection, GetRadioInfo? radioInfo, GuestRadioSettings? guestRadioSettings, - WanExternal? wanExternal, + WanExternalUIModel? wanExternal, bool? isRunning, }) { return InstantVerifyState( @@ -59,7 +59,7 @@ class InstantVerifyState extends Equatable { guestRadioSettings: GuestRadioSettings.fromMap(map['guestRadioSettings']), wanExternal: map['wanExternal'] == null ? null - : WanExternal.fromMap(map['wanExternal']), + : WanExternalUIModel.fromMap(map['wanExternal']), isRunning: map['isRunning'] ?? false, ); } diff --git a/test/core/jnap/models/wan_external_ui_model_test.dart b/test/core/jnap/models/wan_external_ui_model_test.dart new file mode 100644 index 000000000..935052cb1 --- /dev/null +++ b/test/core/jnap/models/wan_external_ui_model_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; + +void main() { + group('WanExternalUIModel', () { + test('creates instance with all fields', () { + const model = WanExternalUIModel( + publicWanIPv4: '203.0.113.1', + publicWanIPv6: '2001:db8::1', + privateWanIPv4: '192.168.1.1', + privateWanIPv6: 'fe80::1', + ); + + expect(model.publicWanIPv4, '203.0.113.1'); + expect(model.publicWanIPv6, '2001:db8::1'); + expect(model.privateWanIPv4, '192.168.1.1'); + expect(model.privateWanIPv6, 'fe80::1'); + }); + + test('creates instance with null fields', () { + const model = WanExternalUIModel(); + + expect(model.publicWanIPv4, isNull); + expect(model.publicWanIPv6, isNull); + expect(model.privateWanIPv4, isNull); + expect(model.privateWanIPv6, isNull); + }); + + group('copyWith', () { + test('copies with new values', () { + const original = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + ); + + final copied = original.copyWith( + publicWanIPv6: '::1', + ); + + expect(copied.publicWanIPv4, '1.1.1.1'); + expect(copied.publicWanIPv6, '::1'); + }); + + test('preserves original values when not specified', () { + const original = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + privateWanIPv4: '192.168.1.1', + ); + + final copied = original.copyWith(); + + expect(copied.publicWanIPv4, '1.1.1.1'); + expect(copied.privateWanIPv4, '192.168.1.1'); + }); + }); + + group('serialization', () { + test('toMap creates correct map', () { + const model = WanExternalUIModel( + publicWanIPv4: '203.0.113.1', + publicWanIPv6: '2001:db8::1', + ); + + final map = model.toMap(); + + expect(map['publicWanIPv4'], '203.0.113.1'); + expect(map['publicWanIPv6'], '2001:db8::1'); + expect(map['privateWanIPv4'], isNull); + expect(map['privateWanIPv6'], isNull); + }); + + test('fromMap creates correct instance', () { + final map = { + 'publicWanIPv4': '203.0.113.1', + 'privateWanIPv4': '192.168.1.1', + }; + + final model = WanExternalUIModel.fromMap(map); + + expect(model.publicWanIPv4, '203.0.113.1'); + expect(model.publicWanIPv6, isNull); + expect(model.privateWanIPv4, '192.168.1.1'); + expect(model.privateWanIPv6, isNull); + }); + + test('toJson and fromJson roundtrip', () { + const original = WanExternalUIModel( + publicWanIPv4: '203.0.113.1', + publicWanIPv6: '2001:db8::1', + privateWanIPv4: '192.168.1.1', + privateWanIPv6: 'fe80::1', + ); + + final json = original.toJson(); + final restored = WanExternalUIModel.fromJson(json); + + expect(restored, original); + }); + }); + + group('equality', () { + test('equal instances have same props', () { + const model1 = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + ); + const model2 = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + ); + + expect(model1, model2); + expect(model1.hashCode, model2.hashCode); + }); + + test('different instances have different props', () { + const model1 = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + ); + const model2 = WanExternalUIModel( + publicWanIPv4: '2.2.2.2', + ); + + expect(model1, isNot(model2)); + }); + }); + }); +} diff --git a/test/core/jnap/providers/wan_external_provider_test.dart b/test/core/jnap/providers/wan_external_provider_test.dart new file mode 100644 index 000000000..7cfd117ec --- /dev/null +++ b/test/core/jnap/providers/wan_external_provider_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/core/jnap/providers/wan_external_provider.dart'; +import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; +import 'package:privacy_gui/core/jnap/services/wan_external_service.dart'; + +class MockWanExternalService extends Mock implements WanExternalService {} + +class MockServiceHelper extends Mock implements ServiceHelper {} + +void main() { + late MockWanExternalService mockService; + late MockServiceHelper mockServiceHelper; + late ProviderContainer container; + + final getIt = GetIt.instance; + + setUpAll(() { + getIt.allowReassignment = true; + }); + + setUp(() { + mockService = MockWanExternalService(); + mockServiceHelper = MockServiceHelper(); + + // Register mock ServiceHelper with GetIt + getIt.registerSingleton(mockServiceHelper); + + // Default stub: WAN External is supported + when(() => mockServiceHelper.isSupportWANExternal()).thenReturn(true); + }); + + tearDown(() { + container.dispose(); + getIt.unregister(); + }); + + group('WANExternalNotifier - build', () { + test('returns initial state with null wanExternal', () { + // Arrange + container = ProviderContainer( + overrides: [ + wanExternalServiceProvider.overrideWithValue(mockService), + ], + ); + + // Act + final state = container.read(wanExternalProvider); + + // Assert + expect(state.wanExternal, isNull); + expect(state.lastUpdate, 0); + }); + }); + + group('WANExternalNotifier - fetch', () { + test('calls service and updates state on success', () async { + // Arrange + const uiModel = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + privateWanIPv4: '192.168.1.1', + ); + + when(() => mockService.fetchWanExternal(force: any(named: 'force'))) + .thenAnswer((_) async => uiModel); + + container = ProviderContainer( + overrides: [ + wanExternalServiceProvider.overrideWithValue(mockService), + ], + ); + + // Act + final result = + await container.read(wanExternalProvider.notifier).fetch(force: true); + + // Assert + verify(() => mockService.fetchWanExternal(force: true)).called(1); + expect(result.wanExternal, uiModel); + expect(result.lastUpdate, greaterThan(0)); + }); + + // Note: Testing isSupportWANExternal() behavior requires integration test + // because the serviceHelper getter is evaluated at library load time. + // The actual business logic (service call, cache, error handling) is tested below. + + test('handles service error gracefully', () async { + // Arrange + when(() => mockService.fetchWanExternal(force: any(named: 'force'))) + .thenThrow(Exception('Service error')); + + container = ProviderContainer( + overrides: [ + wanExternalServiceProvider.overrideWithValue(mockService), + ], + ); + + // Act + final result = + await container.read(wanExternalProvider.notifier).fetch(force: true); + + // Assert - should not throw, just update lastUpdate + expect(result.wanExternal, isNull); + expect(result.lastUpdate, greaterThan(0)); + }); + + test('respects cache timing and skips fetch when within cache period', + () async { + // Arrange + const cachedModel = WanExternalUIModel(publicWanIPv4: 'cached'); + final cachedState = WANExternalState( + wanExternal: cachedModel, + lastUpdate: DateTime.now().millisecondsSinceEpoch, + ); + + when(() => mockService.fetchWanExternal(force: any(named: 'force'))) + .thenAnswer((_) async => const WanExternalUIModel( + publicWanIPv4: 'fresh', + )); + + container = ProviderContainer( + overrides: [ + wanExternalServiceProvider.overrideWithValue(mockService), + wanExternalProvider.overrideWith(() => _MockNotifier(cachedState)), + ], + ); + + // Act + final result = await container.read(wanExternalProvider.notifier).fetch(); + + // Assert - should return cached state, not call service + verifyNever( + () => mockService.fetchWanExternal(force: any(named: 'force'))); + expect(result.wanExternal?.publicWanIPv4, 'cached'); + }); + + test('fetches when cache is expired (> 1 hour)', () async { + // Arrange + const cachedModel = WanExternalUIModel(publicWanIPv4: 'cached'); + final expiredTimestamp = + DateTime.now().millisecondsSinceEpoch - (3600 * 1000 + 1); + final expiredState = WANExternalState( + wanExternal: cachedModel, + lastUpdate: expiredTimestamp, + ); + + const freshModel = WanExternalUIModel(publicWanIPv4: 'fresh'); + + when(() => mockService.fetchWanExternal(force: any(named: 'force'))) + .thenAnswer((_) async => freshModel); + + container = ProviderContainer( + overrides: [ + wanExternalServiceProvider.overrideWithValue(mockService), + wanExternalProvider.overrideWith(() => _MockNotifier(expiredState)), + ], + ); + + // Act + final result = await container.read(wanExternalProvider.notifier).fetch(); + + // Assert - should call service since cache expired + verify(() => mockService.fetchWanExternal(force: false)).called(1); + expect(result.wanExternal?.publicWanIPv4, 'fresh'); + }); + }); +} + +/// Test notifier that starts with a pre-set state +class _MockNotifier extends WANExternalNotifier { + final WANExternalState _initialState; + + _MockNotifier(this._initialState); + + @override + WANExternalState build() => _initialState; +} diff --git a/test/core/jnap/providers/wan_external_state_test.dart b/test/core/jnap/providers/wan_external_state_test.dart new file mode 100644 index 000000000..95e7b013b --- /dev/null +++ b/test/core/jnap/providers/wan_external_state_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; + +void main() { + group('WANExternalState', () { + test('creates instance with default values', () { + const state = WANExternalState(); + + expect(state.wanExternal, isNull); + expect(state.lastUpdate, 0); + }); + + test('creates instance with all fields', () { + const wanExternal = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + ); + const state = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + expect(state.wanExternal, wanExternal); + expect(state.lastUpdate, 1000); + }); + + group('copyWith', () { + test('copies with new wanExternal', () { + const original = WANExternalState(lastUpdate: 1000); + const newWanExternal = WanExternalUIModel(publicWanIPv4: '1.1.1.1'); + + final copied = original.copyWith(wanExternal: newWanExternal); + + expect(copied.wanExternal, newWanExternal); + expect(copied.lastUpdate, 1000); + }); + + test('copies with new lastUpdate', () { + const wanExternal = WanExternalUIModel(publicWanIPv4: '1.1.1.1'); + const original = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + final copied = original.copyWith(lastUpdate: 2000); + + expect(copied.wanExternal, wanExternal); + expect(copied.lastUpdate, 2000); + }); + + test('preserves values when not specified', () { + const wanExternal = WanExternalUIModel(publicWanIPv4: '1.1.1.1'); + const original = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + final copied = original.copyWith(); + + expect(copied.wanExternal, wanExternal); + expect(copied.lastUpdate, 1000); + }); + }); + + group('serialization', () { + test('toMap creates correct map', () { + const wanExternal = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + privateWanIPv4: '192.168.1.1', + ); + const state = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + final map = state.toMap(); + + expect(map['lastUpdate'], 1000); + expect(map['wanExternal'], isA()); + expect(map['wanExternal']['publicWanIPv4'], '1.1.1.1'); + }); + + test('fromMap creates correct instance', () { + final map = { + 'wanExternal': { + 'publicWanIPv4': '1.1.1.1', + }, + 'lastUpdate': 1000, + }; + + final state = WANExternalState.fromMap(map); + + expect(state.wanExternal?.publicWanIPv4, '1.1.1.1'); + expect(state.lastUpdate, 1000); + }); + + test('fromMap handles null wanExternal', () { + final map = { + 'wanExternal': null, + 'lastUpdate': 1000, + }; + + final state = WANExternalState.fromMap(map); + + expect(state.wanExternal, isNull); + expect(state.lastUpdate, 1000); + }); + + test('toJson and fromJson roundtrip', () { + const wanExternal = WanExternalUIModel( + publicWanIPv4: '1.1.1.1', + publicWanIPv6: '::1', + ); + const original = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + final json = original.toJson(); + final restored = WANExternalState.fromJson(json); + + expect(restored.wanExternal, wanExternal); + expect(restored.lastUpdate, 1000); + }); + }); + + group('equality', () { + test('equal instances have same props', () { + const wanExternal = WanExternalUIModel(publicWanIPv4: '1.1.1.1'); + const state1 = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + const state2 = WANExternalState( + wanExternal: wanExternal, + lastUpdate: 1000, + ); + + expect(state1, state2); + }); + + test('different instances have different props', () { + const state1 = WANExternalState(lastUpdate: 1000); + const state2 = WANExternalState(lastUpdate: 2000); + + expect(state1, isNot(state2)); + }); + }); + + test('toString returns correct string', () { + const state = WANExternalState(lastUpdate: 1000); + + expect(state.toString(), contains('WanExternalState')); + expect(state.toString(), contains('lastUpdate: 1000')); + }); + }); +} diff --git a/test/core/jnap/services/wan_external_service_test.dart b/test/core/jnap/services/wan_external_service_test.dart new file mode 100644 index 000000000..1fab80a28 --- /dev/null +++ b/test/core/jnap/services/wan_external_service_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/wan_external_service.dart'; + +import '../../../mocks/test_data/wan_external_test_data.dart'; + +class MockRouterRepository extends Mock implements RouterRepository {} + +void main() { + late WanExternalService service; + late MockRouterRepository mockRepository; + + setUpAll(() { + registerFallbackValue(JNAPAction.getWANExternal); + }); + + setUp(() { + mockRepository = MockRouterRepository(); + service = WanExternalService(mockRepository); + }); + + group('WanExternalService - fetchWanExternal', () { + test('returns WanExternalUIModel from JNAP response', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenAnswer( + (_) async => WanExternalTestData.createWanExternalResponse()); + + // Act + final result = await service.fetchWanExternal(); + + // Assert + expect(result, isA()); + expect(result.publicWanIPv4, '203.0.113.1'); + expect(result.publicWanIPv6, '2001:db8::1'); + expect(result.privateWanIPv4, '192.168.1.1'); + expect(result.privateWanIPv6, 'fe80::1'); + }); + + test('passes force=false by default', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenAnswer( + (_) async => WanExternalTestData.createWanExternalResponse()); + + // Act + await service.fetchWanExternal(); + + // Assert + verify(() => mockRepository.send( + JNAPAction.getWANExternal, + fetchRemote: false, + timeoutMs: 30000, + )).called(1); + }); + + test('passes force=true when specified', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenAnswer( + (_) async => WanExternalTestData.createWanExternalResponse()); + + // Act + await service.fetchWanExternal(force: true); + + // Assert + verify(() => mockRepository.send( + JNAPAction.getWANExternal, + fetchRemote: true, + timeoutMs: 30000, + )).called(1); + }); + + test('handles IPv4 only response', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenAnswer( + (_) async => WanExternalTestData.createIpv4OnlyResponse()); + + // Act + final result = await service.fetchWanExternal(); + + // Assert + expect(result.publicWanIPv4, '203.0.113.1'); + expect(result.publicWanIPv6, isNull); + expect(result.privateWanIPv4, '192.168.1.1'); + expect(result.privateWanIPv6, isNull); + }); + + test('handles empty response', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )).thenAnswer((_) async => WanExternalTestData.createEmptyResponse()); + + // Act + final result = await service.fetchWanExternal(); + + // Assert + expect(result.publicWanIPv4, isNull); + expect(result.publicWanIPv6, isNull); + expect(result.privateWanIPv4, isNull); + expect(result.privateWanIPv6, isNull); + }); + + test('throws UnauthorizedError on auth failure', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )).thenThrow(WanExternalTestData.createUnauthorizedError()); + + // Act & Assert + expect( + () => service.fetchWanExternal(), + throwsA(isA()), + ); + }); + + test('throws UnexpectedError on generic JNAP error', () async { + // Arrange + when(() => mockRepository.send( + any(), + fetchRemote: any(named: 'fetchRemote'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenThrow(WanExternalTestData.createUnexpectedError('ErrorUnknown')); + + // Act & Assert + expect( + () => service.fetchWanExternal(), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/mocks/test_data/wan_external_test_data.dart b/test/mocks/test_data/wan_external_test_data.dart new file mode 100644 index 000000000..1e4149ee9 --- /dev/null +++ b/test/mocks/test_data/wan_external_test_data.dart @@ -0,0 +1,54 @@ +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; + +/// Test data builder for WanExternalService tests +/// +/// Provides factory methods to create JNAP mock responses with sensible defaults. +class WanExternalTestData { + /// Create a successful JNAP response for getWANExternal action + static JNAPSuccess createWanExternalResponse({ + String? publicWanIPv4 = '203.0.113.1', + String? publicWanIPv6 = '2001:db8::1', + String? privateWanIPv4 = '192.168.1.1', + String? privateWanIPv6 = 'fe80::1', + }) => + JNAPSuccess( + result: 'ok', + output: { + if (publicWanIPv4 != null) 'PublicWanIPv4': publicWanIPv4, + if (publicWanIPv6 != null) 'PublicWanIPv6': publicWanIPv6, + if (privateWanIPv4 != null) 'PrivateWanIPv4': privateWanIPv4, + if (privateWanIPv6 != null) 'PrivateWanIPv6': privateWanIPv6, + }, + ); + + /// Create a successful response with only IPv4 addresses + static JNAPSuccess createIpv4OnlyResponse({ + String publicWanIPv4 = '203.0.113.1', + String privateWanIPv4 = '192.168.1.1', + }) => + JNAPSuccess( + result: 'ok', + output: { + 'PublicWanIPv4': publicWanIPv4, + 'PrivateWanIPv4': privateWanIPv4, + }, + ); + + /// Create a successful response with empty data + static JNAPSuccess createEmptyResponse() => const JNAPSuccess( + result: 'ok', + output: {}, + ); + + /// Create an unauthorized error response + static JNAPError createUnauthorizedError() => const JNAPError( + result: '_ErrorUnauthorized', + error: null, + ); + + /// Create an unexpected error response + static JNAPError createUnexpectedError([String? message]) => JNAPError( + result: message ?? 'ErrorUnknown', + error: null, + ); +} From 4db08924e344b19a2afe1128980cc29ee8250b3c Mon Sep 17 00:00:00 2001 From: Peter Jhong Date: Mon, 5 Jan 2026 14:23:00 +0800 Subject: [PATCH 2/3] PRIV-2.0.0: fix(wan_external): resolve WanExternalUIModel type conflicts - Remove duplicate WanExternalUIModel from core/jnap/models - Use unified WanExternalUIModel from instant_verify_ui_models.dart - Remove transformWanExternal method and related test mocks - Update imports to use consistent UI model definition --- .../jnap/models/wan_external_ui_model.dart | 77 ----------- .../jnap/providers/wan_external_state.dart | 2 +- .../jnap/services/wan_external_service.dart | 2 +- .../providers/instant_verify_provider.dart | 3 +- .../services/instant_verify_service.dart | 9 -- .../models/wan_external_ui_model_test.dart | 125 ------------------ .../providers/wan_external_provider_test.dart | 2 +- .../providers/wan_external_state_test.dart | 2 +- .../services/wan_external_service_test.dart | 2 +- .../instant_verify_provider_test.dart | 23 +--- .../services/instant_verify_service_test.dart | 23 +--- 11 files changed, 12 insertions(+), 258 deletions(-) delete mode 100644 lib/core/jnap/models/wan_external_ui_model.dart delete mode 100644 test/core/jnap/models/wan_external_ui_model_test.dart diff --git a/lib/core/jnap/models/wan_external_ui_model.dart b/lib/core/jnap/models/wan_external_ui_model.dart deleted file mode 100644 index b6d3fbaaa..000000000 --- a/lib/core/jnap/models/wan_external_ui_model.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:convert'; - -import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; - -/// UI model for WAN external IP information. -/// -/// Isolates the presentation layer from JNAP data models. -/// Contains public and private WAN IPv4/IPv6 addresses. -class WanExternalUIModel extends Equatable { - final String? publicWanIPv4; - final String? publicWanIPv6; - final String? privateWanIPv4; - final String? privateWanIPv6; - - const WanExternalUIModel({ - this.publicWanIPv4, - this.publicWanIPv6, - this.privateWanIPv4, - this.privateWanIPv6, - }); - - /// Creates a UI model from JNAP WanExternal model. - factory WanExternalUIModel.fromJnap(WanExternal jnap) { - return WanExternalUIModel( - publicWanIPv4: jnap.publicWanIPv4, - publicWanIPv6: jnap.publicWanIPv6, - privateWanIPv4: jnap.privateWanIPv4, - privateWanIPv6: jnap.privateWanIPv6, - ); - } - - WanExternalUIModel copyWith({ - String? publicWanIPv4, - String? publicWanIPv6, - String? privateWanIPv4, - String? privateWanIPv6, - }) { - return WanExternalUIModel( - publicWanIPv4: publicWanIPv4 ?? this.publicWanIPv4, - publicWanIPv6: publicWanIPv6 ?? this.publicWanIPv6, - privateWanIPv4: privateWanIPv4 ?? this.privateWanIPv4, - privateWanIPv6: privateWanIPv6 ?? this.privateWanIPv6, - ); - } - - Map toMap() { - return { - 'publicWanIPv4': publicWanIPv4, - 'publicWanIPv6': publicWanIPv6, - 'privateWanIPv4': privateWanIPv4, - 'privateWanIPv6': privateWanIPv6, - }; - } - - factory WanExternalUIModel.fromMap(Map map) { - return WanExternalUIModel( - publicWanIPv4: map['publicWanIPv4'] as String?, - publicWanIPv6: map['publicWanIPv6'] as String?, - privateWanIPv4: map['privateWanIPv4'] as String?, - privateWanIPv6: map['privateWanIPv6'] as String?, - ); - } - - String toJson() => json.encode(toMap()); - - factory WanExternalUIModel.fromJson(String source) => - WanExternalUIModel.fromMap(json.decode(source) as Map); - - @override - List get props => [ - publicWanIPv4, - publicWanIPv6, - privateWanIPv4, - privateWanIPv6, - ]; -} diff --git a/lib/core/jnap/providers/wan_external_state.dart b/lib/core/jnap/providers/wan_external_state.dart index 3e22975a2..82a581228 100644 --- a/lib/core/jnap/providers/wan_external_state.dart +++ b/lib/core/jnap/providers/wan_external_state.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; class WANExternalState extends Equatable { final WanExternalUIModel? wanExternal; diff --git a/lib/core/jnap/services/wan_external_service.dart b/lib/core/jnap/services/wan_external_service.dart index 69b4823ca..65371b301 100644 --- a/lib/core/jnap/services/wan_external_service.dart +++ b/lib/core/jnap/services/wan_external_service.dart @@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/models/wan_external.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/logger.dart'; diff --git a/lib/page/instant_verify/providers/instant_verify_provider.dart b/lib/page/instant_verify/providers/instant_verify_provider.dart index de1885aee..ec31ae8d5 100644 --- a/lib/page/instant_verify/providers/instant_verify_provider.dart +++ b/lib/page/instant_verify/providers/instant_verify_provider.dart @@ -25,13 +25,12 @@ class InstantVerifyNotifier extends Notifier { final wanConnection = service.parseWanConnection(pollingData); final radioInfo = service.parseRadioInfo(pollingData); final guestRadioSettings = service.parseGuestRadioSettings(pollingData); - final wanExternal = service.transformWanExternal(wanExternalData); return InstantVerifyState( wanConnection: wanConnection, radioInfo: radioInfo, guestRadioSettings: guestRadioSettings, - wanExternal: wanExternal, + wanExternal: wanExternalData, ); } diff --git a/lib/page/instant_verify/services/instant_verify_service.dart b/lib/page/instant_verify/services/instant_verify_service.dart index d95981472..e0de9cbf3 100644 --- a/lib/page/instant_verify/services/instant_verify_service.dart +++ b/lib/page/instant_verify/services/instant_verify_service.dart @@ -7,7 +7,6 @@ import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; import 'package:privacy_gui/core/jnap/models/ping_status.dart'; import 'package:privacy_gui/core/jnap/models/radio_info.dart'; import 'package:privacy_gui/core/jnap/models/traceroute_status.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; import 'package:privacy_gui/core/jnap/models/wan_status.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; @@ -78,14 +77,6 @@ class InstantVerifyService { return GuestRadioSettingsUIModel.fromJnap(guestRadioSettings); } - /// Transform WanExternal JNAP model to UI model - /// - /// Returns: WAN external UI model, null if input is null - WanExternalUIModel? transformWanExternal(WanExternal? wanExternal) { - if (wanExternal == null) return null; - return WanExternalUIModel.fromJnap(wanExternal); - } - /// Starts a Ping test to the specified host /// /// Parameters: diff --git a/test/core/jnap/models/wan_external_ui_model_test.dart b/test/core/jnap/models/wan_external_ui_model_test.dart deleted file mode 100644 index 935052cb1..000000000 --- a/test/core/jnap/models/wan_external_ui_model_test.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; - -void main() { - group('WanExternalUIModel', () { - test('creates instance with all fields', () { - const model = WanExternalUIModel( - publicWanIPv4: '203.0.113.1', - publicWanIPv6: '2001:db8::1', - privateWanIPv4: '192.168.1.1', - privateWanIPv6: 'fe80::1', - ); - - expect(model.publicWanIPv4, '203.0.113.1'); - expect(model.publicWanIPv6, '2001:db8::1'); - expect(model.privateWanIPv4, '192.168.1.1'); - expect(model.privateWanIPv6, 'fe80::1'); - }); - - test('creates instance with null fields', () { - const model = WanExternalUIModel(); - - expect(model.publicWanIPv4, isNull); - expect(model.publicWanIPv6, isNull); - expect(model.privateWanIPv4, isNull); - expect(model.privateWanIPv6, isNull); - }); - - group('copyWith', () { - test('copies with new values', () { - const original = WanExternalUIModel( - publicWanIPv4: '1.1.1.1', - ); - - final copied = original.copyWith( - publicWanIPv6: '::1', - ); - - expect(copied.publicWanIPv4, '1.1.1.1'); - expect(copied.publicWanIPv6, '::1'); - }); - - test('preserves original values when not specified', () { - const original = WanExternalUIModel( - publicWanIPv4: '1.1.1.1', - privateWanIPv4: '192.168.1.1', - ); - - final copied = original.copyWith(); - - expect(copied.publicWanIPv4, '1.1.1.1'); - expect(copied.privateWanIPv4, '192.168.1.1'); - }); - }); - - group('serialization', () { - test('toMap creates correct map', () { - const model = WanExternalUIModel( - publicWanIPv4: '203.0.113.1', - publicWanIPv6: '2001:db8::1', - ); - - final map = model.toMap(); - - expect(map['publicWanIPv4'], '203.0.113.1'); - expect(map['publicWanIPv6'], '2001:db8::1'); - expect(map['privateWanIPv4'], isNull); - expect(map['privateWanIPv6'], isNull); - }); - - test('fromMap creates correct instance', () { - final map = { - 'publicWanIPv4': '203.0.113.1', - 'privateWanIPv4': '192.168.1.1', - }; - - final model = WanExternalUIModel.fromMap(map); - - expect(model.publicWanIPv4, '203.0.113.1'); - expect(model.publicWanIPv6, isNull); - expect(model.privateWanIPv4, '192.168.1.1'); - expect(model.privateWanIPv6, isNull); - }); - - test('toJson and fromJson roundtrip', () { - const original = WanExternalUIModel( - publicWanIPv4: '203.0.113.1', - publicWanIPv6: '2001:db8::1', - privateWanIPv4: '192.168.1.1', - privateWanIPv6: 'fe80::1', - ); - - final json = original.toJson(); - final restored = WanExternalUIModel.fromJson(json); - - expect(restored, original); - }); - }); - - group('equality', () { - test('equal instances have same props', () { - const model1 = WanExternalUIModel( - publicWanIPv4: '1.1.1.1', - ); - const model2 = WanExternalUIModel( - publicWanIPv4: '1.1.1.1', - ); - - expect(model1, model2); - expect(model1.hashCode, model2.hashCode); - }); - - test('different instances have different props', () { - const model1 = WanExternalUIModel( - publicWanIPv4: '1.1.1.1', - ); - const model2 = WanExternalUIModel( - publicWanIPv4: '2.2.2.2', - ); - - expect(model1, isNot(model2)); - }); - }); - }); -} diff --git a/test/core/jnap/providers/wan_external_provider_test.dart b/test/core/jnap/providers/wan_external_provider_test.dart index 7cfd117ec..6ef2fcb87 100644 --- a/test/core/jnap/providers/wan_external_provider_test.dart +++ b/test/core/jnap/providers/wan_external_provider_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_provider.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; import 'package:privacy_gui/core/jnap/services/wan_external_service.dart'; diff --git a/test/core/jnap/providers/wan_external_state_test.dart b/test/core/jnap/providers/wan_external_state_test.dart index 95e7b013b..f70cb1029 100644 --- a/test/core/jnap/providers/wan_external_state_test.dart +++ b/test/core/jnap/providers/wan_external_state_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; void main() { diff --git a/test/core/jnap/services/wan_external_service_test.dart b/test/core/jnap/services/wan_external_service_test.dart index 1fab80a28..0aa6d9bf2 100644 --- a/test/core/jnap/services/wan_external_service_test.dart +++ b/test/core/jnap/services/wan_external_service_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external_ui_model.dart'; +import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/jnap/services/wan_external_service.dart'; diff --git a/test/page/instant_verify/providers/instant_verify_provider_test.dart b/test/page/instant_verify/providers/instant_verify_provider_test.dart index fedd59a19..816e14c3a 100644 --- a/test/page/instant_verify/providers/instant_verify_provider_test.dart +++ b/test/page/instant_verify/providers/instant_verify_provider_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; + import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_provider.dart'; import 'package:privacy_gui/core/jnap/providers/wan_external_state.dart'; @@ -31,7 +31,7 @@ void main() { ProviderContainer createContainer({ Map? pollingData, - WanExternal? wanExternal, + WanExternalUIModel? wanExternal, }) { return ProviderContainer( overrides: [ @@ -54,7 +54,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); container = createContainer(); @@ -87,7 +86,6 @@ void main() { .thenReturn(expectedRadioInfo); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); container = createContainer(); @@ -99,23 +97,18 @@ void main() { verify(() => mockService.parseRadioInfo(any())).called(1); }); - test('transforms wanExternal from provider', () { + test('uses wanExternal directly from provider', () { const expectedWanExternal = WanExternalUIModel( publicWanIPv4: '203.0.113.1', ); - const sourceWanExternal = WanExternal( - publicWanIPv4: '203.0.113.1', - ); when(() => mockService.parseWanConnection(any())).thenReturn(null); when(() => mockService.parseRadioInfo(any())) .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(sourceWanExternal)) - .thenReturn(expectedWanExternal); - container = createContainer(wanExternal: sourceWanExternal); + container = createContainer(wanExternal: expectedWanExternal); final state = container.read(instantVerifyProvider); @@ -130,7 +123,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.startPing( host: any(named: 'host'), pingCount: any(named: 'pingCount'), @@ -155,7 +147,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.stopPing()).thenAnswer((_) async {}); container = createContainer(); @@ -176,7 +167,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.getPingStatus( onCompleted: any(named: 'onCompleted'))) .thenAnswer((_) => Stream.fromIterable([ @@ -201,7 +191,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.startTraceroute(host: '8.8.8.8')) .thenAnswer((_) async {}); @@ -224,7 +213,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.stopTraceroute()).thenAnswer((_) async => null); container = createContainer(); @@ -245,7 +233,6 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.transformWanExternal(any())).thenReturn(null); when(() => mockService.getTracerouteStatus( onCompleted: any(named: 'onCompleted'))) .thenAnswer((_) => Stream.fromIterable([ @@ -288,7 +275,7 @@ class MockPollingNotifier extends AsyncNotifier class MockWanExternalNotifier extends Notifier implements WANExternalNotifier { - final WanExternal? _wanExternal; + final WanExternalUIModel? _wanExternal; MockWanExternalNotifier(this._wanExternal); diff --git a/test/page/instant_verify/services/instant_verify_service_test.dart b/test/page/instant_verify/services/instant_verify_service_test.dart index 9ca8b0b6c..3548a161b 100644 --- a/test/page/instant_verify/services/instant_verify_service_test.dart +++ b/test/page/instant_verify/services/instant_verify_service_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/wan_external.dart'; + import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/instant_verify/models/instant_verify_ui_models.dart'; @@ -175,26 +175,5 @@ void main() { expect(result.radios.first.guestSSID, 'Guest_Network'); }); }); - - group('transformWanExternal', () { - test('returns null when input is null', () { - final result = service.transformWanExternal(null); - - expect(result, isNull); - }); - - test('returns WanExternalUIModel when valid WanExternal provided', () { - final wanExternal = WanExternal( - publicWanIPv4: '203.0.113.1', - privateWanIPv4: '192.168.1.100', - ); - - final result = service.transformWanExternal(wanExternal); - - expect(result, isNotNull); - expect(result!.publicWanIPv4, '203.0.113.1'); - expect(result.privateWanIPv4, '192.168.1.100'); - }); - }); }); } From cd34e10ce3682a84c34adebc2b28e2a1d4b96abf Mon Sep 17 00:00:00 2001 From: Peter Jhong Date: Mon, 5 Jan 2026 14:40:15 +0800 Subject: [PATCH 3/3] PRIV-2.0.0: fix(wan_external): respect force parameter in cache check --- lib/core/jnap/providers/wan_external_provider.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/core/jnap/providers/wan_external_provider.dart b/lib/core/jnap/providers/wan_external_provider.dart index 18e8ae70a..428dbe527 100644 --- a/lib/core/jnap/providers/wan_external_provider.dart +++ b/lib/core/jnap/providers/wan_external_provider.dart @@ -20,8 +20,9 @@ class WANExternalNotifier extends Notifier { if (!serviceHelper.isSupportWANExternal()) { return state; } - if (DateTime.now().millisecondsSinceEpoch - state.lastUpdate < - 3600 * 1000) { + if (!force && + DateTime.now().millisecondsSinceEpoch - state.lastUpdate < + 3600 * 1000) { return state; }