Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 14 additions & 17 deletions lib/core/jnap/providers/wan_external_provider.dart
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -15,31 +13,30 @@ final wanExternalProvider =
class WANExternalNotifier extends Notifier<WANExternalState> {
@override
WANExternalState build() {
return WANExternalState();
return const WANExternalState();
}

FutureOr<WANExternalState> fetch({bool force = false}) {
FutureOr<WANExternalState> fetch({bool force = false}) async {
if (!serviceHelper.isSupportWANExternal()) {
return state;
}
if (DateTime.now().millisecondsSinceEpoch - state.lastUpdate <
3600 * 1000) {
if (!force &&
DateTime.now().millisecondsSinceEpoch - state.lastUpdate <
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;
}
}
}
8 changes: 4 additions & 4 deletions lib/core/jnap/providers/wan_external_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/page/instant_verify/models/instant_verify_ui_models.dart';

class WANExternalState extends Equatable {
final WanExternal? wanExternal;
final WanExternalUIModel? wanExternal;
final int lastUpdate;

const WANExternalState({
Expand All @@ -14,7 +14,7 @@ class WANExternalState extends Equatable {
});

WANExternalState copyWith({
WanExternal? wanExternal,
WanExternalUIModel? wanExternal,
int? lastUpdate,
}) {
return WANExternalState(
Expand All @@ -33,7 +33,7 @@ class WANExternalState extends Equatable {
factory WANExternalState.fromMap(Map<String, dynamic> map) {
return WANExternalState(
wanExternal: map['wanExternal'] != null
? WanExternal.fromMap(map['wanExternal'])
? WanExternalUIModel.fromMap(map['wanExternal'])
: null,
lastUpdate: map['lastUpdate']?.toInt() ?? 0,
);
Expand Down
57 changes: 57 additions & 0 deletions lib/core/jnap/services/wan_external_service.dart
Original file line number Diff line number Diff line change
@@ -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/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';

/// Riverpod provider for WanExternalService.
final wanExternalServiceProvider = Provider<WanExternalService>((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<WanExternalUIModel> 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),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,12 @@ class InstantVerifyNotifier extends Notifier<InstantVerifyState> {
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,
);
}

Expand Down
9 changes: 0 additions & 9 deletions lib/page/instant_verify/services/instant_verify_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down
181 changes: 181 additions & 0 deletions test/core/jnap/providers/wan_external_provider_test.dart
Original file line number Diff line number Diff line change
@@ -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/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';

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<ServiceHelper>(mockServiceHelper);

// Default stub: WAN External is supported
when(() => mockServiceHelper.isSupportWANExternal()).thenReturn(true);
});

tearDown(() {
container.dispose();
getIt.unregister<ServiceHelper>();
});

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;
}
Loading