From 2833906c6893a89e72df15d6849f1e88b45d5fe3 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 11 Mar 2026 23:13:52 -0600 Subject: [PATCH 1/3] Add centralized retry/fallback policy with hard-coded endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract duplicated retry logic from OverpassService and RoutingService into a shared resilience framework in service_policy.dart: - ResiliencePolicy: configurable retries, backoff, and HTTP timeout - executeWithFallback: retry loop with primary→fallback endpoint chain - ErrorDisposition enum: abort / fallback / retry classification - ServicePolicy + ServicePolicyResolver: per-service compliance rules (rate limits, caching, concurrency) for OSMF and third-party services - ServiceRateLimiter: async semaphore-based concurrency and rate control OverpassService now hits overpass.deflock.org first, falls back to overpass-api.de. RoutingService hits api.dontgetflocked.com first, falls back to alprwatch.org. Both use per-service error classifiers to determine retry vs fallback vs abort behavior. Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 3 - lib/services/overpass_service.dart | 179 ++++++------ lib/services/routing_service.dart | 98 +++++-- lib/services/service_policy.dart | 141 ++++++--- lib/state/navigation_state.dart | 6 +- test/services/overpass_service_test.dart | 200 ++++++++++++- test/services/routing_service_test.dart | 351 +++++++++++++++++++++-- test/services/service_policy_test.dart | 254 ++++++++++++---- 8 files changed, 965 insertions(+), 267 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 44294c69..e10bfc97 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -64,9 +64,6 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close const double kChangesetCloseBackoffMultiplier = 2.0; -// Navigation routing configuration -const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests - // Overpass API configuration const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 0d0f7ca5..05b8c186 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -8,97 +8,106 @@ import '../models/node_profile.dart'; import '../models/osm_node.dart'; import '../dev_config.dart'; import 'http_client.dart'; +import 'service_policy.dart'; -/// Simple Overpass API client with proper HTTP retry logic. +/// Simple Overpass API client with retry and fallback logic. /// Single responsibility: Make requests, handle network errors, return data. class OverpassService { - static const String _endpoint = 'https://overpass-api.de/api/interpreter'; + static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter'; + static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter'; + static const _policy = ResiliencePolicy( + maxRetries: 3, + httpTimeout: Duration(seconds: 45), + ); + final http.Client _client; + /// Optional override endpoint. When null, uses [defaultEndpoint]. + final String? _endpointOverride; - OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); + OverpassService({http.Client? client, String? endpoint}) + : _client = client ?? UserAgentClient(), + _endpointOverride = endpoint; + /// Resolve the primary endpoint: constructor override or default. + String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; - /// Fetch surveillance nodes from Overpass API with proper retry logic. + /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ required LatLngBounds bounds, required List profiles, - int maxRetries = 3, + ResiliencePolicy? policy, }) async { if (profiles.isEmpty) return []; - + final query = _buildQuery(bounds, profiles); - - for (int attempt = 0; attempt <= maxRetries; attempt++) { - try { - debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles'); - - final response = await _client.post( - Uri.parse(_endpoint), - body: {'data': query}, - ).timeout(kOverpassQueryTimeout); - - if (response.statusCode == 200) { - return _parseResponse(response.body); - } - - // Check for specific error types - final errorBody = response.body; - - // Node limit error - caller should split area - if (response.statusCode == 400 && - (errorBody.contains('too many nodes') && errorBody.contains('50000'))) { - debugPrint('[OverpassService] Node limit exceeded, area should be split'); - throw NodeLimitError('Query exceeded 50k node limit'); - } - - // Timeout error - also try splitting (complex query) - if (errorBody.contains('timeout') || - errorBody.contains('runtime limit exceeded') || - errorBody.contains('Query timed out')) { - debugPrint('[OverpassService] Query timeout, area should be split'); - throw NodeLimitError('Query timed out - area too complex'); - } - - // Rate limit - throw immediately, don't retry - if (response.statusCode == 429 || - errorBody.contains('rate limited') || - errorBody.contains('too many requests')) { - debugPrint('[OverpassService] Rate limited by Overpass'); - throw RateLimitError('Rate limited by Overpass API'); - } - - // Other HTTP errors - retry with backoff - if (attempt < maxRetries) { - final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000)); - debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms'); - await Future.delayed(delay); - continue; - } - - throw NetworkError('HTTP ${response.statusCode}: $errorBody'); - - } catch (e) { - // Handle specific error types without retry - if (e is NodeLimitError || e is RateLimitError) { - rethrow; - } - - // Network/timeout errors - retry with backoff - if (attempt < maxRetries) { - final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000)); - debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms'); - await Future.delayed(delay); - continue; - } - - throw NetworkError('Network error after $maxRetries retries: $e'); + final endpoint = _primaryEndpoint; + final canFallback = _endpointOverride == null; + final effectivePolicy = policy ?? _policy; + + return executeWithFallback>( + primaryUrl: endpoint, + fallbackUrl: canFallback ? fallbackEndpoint : null, + execute: (url) => _attemptFetch(url, query, effectivePolicy), + classifyError: _classifyError, + policy: effectivePolicy, + ); + } + + /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). + Future> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async { + debugPrint('[OverpassService] POST $endpoint'); + + try { + final response = await _client.post( + Uri.parse(endpoint), + body: {'data': query}, + ).timeout(policy.httpTimeout); + + if (response.statusCode == 200) { + return _parseResponse(response.body); } + + final errorBody = response.body; + + // Node limit error - caller should split area + if (response.statusCode == 400 && + (errorBody.contains('too many nodes') && errorBody.contains('50000'))) { + debugPrint('[OverpassService] Node limit exceeded, area should be split'); + throw NodeLimitError('Query exceeded 50k node limit'); + } + + // Timeout error - also try splitting (complex query) + if (errorBody.contains('timeout') || + errorBody.contains('runtime limit exceeded') || + errorBody.contains('Query timed out')) { + debugPrint('[OverpassService] Query timeout, area should be split'); + throw NodeLimitError('Query timed out - area too complex'); + } + + // Rate limit + if (response.statusCode == 429 || + errorBody.contains('rate limited') || + errorBody.contains('too many requests')) { + debugPrint('[OverpassService] Rate limited by Overpass'); + throw RateLimitError('Rate limited by Overpass API'); + } + + throw NetworkError('HTTP ${response.statusCode}: $errorBody'); + } catch (e) { + if (e is NodeLimitError || e is RateLimitError || e is NetworkError) { + rethrow; + } + throw NetworkError('Network error: $e'); } - - throw NetworkError('Max retries exceeded'); } - + + static ErrorDisposition _classifyError(Object error) { + if (error is NodeLimitError) return ErrorDisposition.abort; + if (error is RateLimitError) return ErrorDisposition.fallback; + return ErrorDisposition.retry; + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { @@ -107,7 +116,7 @@ class OverpassService { .where((entry) => entry.value.trim().isNotEmpty) .map((entry) => '["${entry.key}"="${entry.value}"]') .join(); - + return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});'; }).join('\n '); @@ -119,38 +128,38 @@ class OverpassService { out body; ( way(bn); - rel(bn); + rel(bn); ); out skel; '''; } - + /// Parse Overpass JSON response into OsmNode objects List _parseResponse(String responseBody) { final data = jsonDecode(responseBody) as Map; final elements = data['elements'] as List; - + final nodeElements = >[]; final constrainedNodeIds = {}; - + // First pass: collect surveillance nodes and identify constrained nodes for (final element in elements.whereType>()) { final type = element['type'] as String?; - + if (type == 'node') { nodeElements.add(element); } else if (type == 'way' || type == 'relation') { // Mark referenced nodes as constrained - final refs = element['nodes'] as List? ?? + final refs = element['nodes'] as List? ?? element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? []; - + for (final ref in refs) { final nodeId = ref is int ? ref : int.tryParse(ref.toString()); if (nodeId != null) constrainedNodeIds.add(nodeId); } } } - + // Second pass: create OsmNode objects final nodes = nodeElements.map((element) { final nodeId = element['id'] as int; @@ -161,7 +170,7 @@ out skel; isConstrained: constrainedNodeIds.contains(nodeId), ); }).toList(); - + debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained'); return nodes; } @@ -189,4 +198,4 @@ class NetworkError extends Error { NetworkError(this.message); @override String toString() => 'NetworkError: $message'; -} \ No newline at end of file +} diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 3163f496..072a54af 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; -import '../dev_config.dart'; import 'http_client.dart'; +import 'service_policy.dart'; class RouteResult { final List waypoints; final double distanceMeters; final double durationSeconds; - + const RouteResult({ required this.waypoints, required this.distanceMeters, required this.durationSeconds, }); - + @override String toString() { return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)'; @@ -26,14 +26,27 @@ class RouteResult { } class RoutingService { - static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions'; + static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions'; + static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions'; + static const _policy = ResiliencePolicy( + maxRetries: 1, + httpTimeout: Duration(seconds: 30), + ); + final http.Client _client; + /// Optional override URL. When null, uses [defaultUrl]. + final String? _baseUrlOverride; - RoutingService({http.Client? client}) : _client = client ?? UserAgentClient(); + RoutingService({http.Client? client, String? baseUrl}) + : _client = client ?? UserAgentClient(), + _baseUrlOverride = baseUrl; void close() => _client.close(); - // Calculate route between two points using alprwatch + /// Resolve the primary URL to use: constructor override or default. + String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + + // Calculate route between two points Future calculateRoute({ required LatLng start, required LatLng end, @@ -53,8 +66,7 @@ class RoutingService { 'tags': tags, }; }).toList(); - - final uri = Uri.parse(_baseUrl); + final params = { 'start': { 'longitude': start.longitude, @@ -66,11 +78,25 @@ class RoutingService { }, 'avoidance_distance': avoidanceDistance, 'enabled_profiles': enabledProfiles, - 'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route + 'show_exclusion_zone': false, }; - - debugPrint('[RoutingService] alprwatch request: $uri $params'); - + + final primaryUrl = _primaryUrl; + final canFallback = _baseUrlOverride == null; + + return executeWithFallback( + primaryUrl: primaryUrl, + fallbackUrl: canFallback ? fallbackUrl : null, + execute: (url) => _postRoute(url, params), + classifyError: _classifyError, + policy: _policy, + ); + } + + Future _postRoute(String url, Map params) async { + final uri = Uri.parse(url); + debugPrint('[RoutingService] POST $uri'); + try { final response = await _client.post( uri, @@ -78,7 +104,7 @@ class RoutingService { 'Content-Type': 'application/json' }, body: json.encode(params) - ).timeout(kNavigationRoutingTimeout); + ).timeout(_policy.httpTimeout); if (response.statusCode != 200) { if (kDebugMode) { @@ -91,24 +117,25 @@ class RoutingService { : body; debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated'); } - throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}', + statusCode: response.statusCode); } - + final data = json.decode(response.body) as Map; - debugPrint('[RoutingService] alprwatch response data: $data'); - - // Check alprwatch response status + debugPrint('[RoutingService] response data: $data'); + + // Check response status final ok = data['ok'] as bool? ?? false; if ( ! ok ) { final message = data['error'] as String? ?? 'Unknown routing error'; - throw RoutingException('alprwatch error: $message'); + throw RoutingException('API error: $message', isApiError: true); } - + final route = data['result']['route'] as Map?; if (route == null) { - throw RoutingException('No route found between these points'); + throw RoutingException('No route found between these points', isApiError: true); } - + final waypoints = (route['coordinates'] as List?) ?.map((inner) { final pair = inner as List; @@ -116,19 +143,19 @@ class RoutingService { final lng = (pair[0] as num).toDouble(); final lat = (pair[1] as num).toDouble(); return LatLng(lat, lng); - }).whereType().toList() ?? []; + }).whereType().toList() ?? []; final distance = (route['distance'] as num?)?.toDouble() ?? 0.0; final duration = (route['duration'] as num?)?.toDouble() ?? 0.0; - + final result = RouteResult( waypoints: waypoints, distanceMeters: distance, durationSeconds: duration, ); - + debugPrint('[RoutingService] Route calculated: $result'); return result; - + } catch (e) { debugPrint('[RoutingService] Route calculation failed: $e'); if (e is RoutingException) { @@ -138,13 +165,26 @@ class RoutingService { } } } + + static ErrorDisposition _classifyError(Object error) { + if (error is! RoutingException) return ErrorDisposition.retry; + if (error.isApiError) return ErrorDisposition.abort; + final status = error.statusCode; + if (status != null && status >= 400 && status < 500) { + if (status == 429) return ErrorDisposition.fallback; + return ErrorDisposition.abort; + } + return ErrorDisposition.retry; + } } class RoutingException implements Exception { final String message; - - const RoutingException(this.message); - + final int? statusCode; + final bool isApiError; + + const RoutingException(this.message, {this.statusCode, this.isApiError = false}); + @override String toString() => 'RoutingException: $message'; } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index acf8a246..8fd66cf2 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -152,11 +152,10 @@ class ServicePolicy { 'attributionUrl: $attributionUrl)'; } -/// Resolves URLs and tile providers to their applicable [ServicePolicy]. +/// Resolves service URLs to their applicable [ServicePolicy]. /// /// Built-in patterns cover all OSMF official services and common third-party -/// tile providers. Custom overrides can be registered for self-hosted endpoints -/// via [registerCustomPolicy]. +/// tile providers. Falls back to permissive defaults for unrecognized hosts. class ServicePolicyResolver { /// Host → ServiceType mapping for known services. static final Map _hostPatterns = { @@ -166,6 +165,7 @@ class ServicePolicyResolver { 'tile.openstreetmap.org': ServiceType.osmTileServer, 'nominatim.openstreetmap.org': ServiceType.nominatim, 'overpass-api.de': ServiceType.overpass, + 'overpass.deflock.org': ServiceType.overpass, 'taginfo.openstreetmap.org': ServiceType.tagInfo, 'tiles.virtualearth.net': ServiceType.bingTiles, 'api.mapbox.com': ServiceType.mapboxTiles, @@ -183,25 +183,14 @@ class ServicePolicyResolver { ServiceType.custom: const ServicePolicy(), }; - /// Custom host overrides registered at runtime (for self-hosted services). - static final Map _customOverrides = {}; - /// Resolve a URL to its applicable [ServicePolicy]. /// - /// Checks custom overrides first, then built-in host patterns. Falls back - /// to [ServicePolicy.custom] for unrecognized hosts. + /// Checks built-in host patterns. Falls back to [ServicePolicy.custom] + /// for unrecognized hosts. static ServicePolicy resolve(String url) { final host = _extractHost(url); if (host == null) return const ServicePolicy(); - // Check custom overrides first (exact or subdomain matching) - for (final entry in _customOverrides.entries) { - if (host == entry.key || host.endsWith('.${entry.key}')) { - return entry.value; - } - } - - // Check built-in patterns (support subdomain matching) for (final entry in _hostPatterns.entries) { if (host == entry.key || host.endsWith('.${entry.key}')) { return _policies[entry.value] ?? const ServicePolicy(); @@ -218,14 +207,6 @@ class ServicePolicyResolver { final host = _extractHost(url); if (host == null) return ServiceType.custom; - // Check custom overrides first — a registered custom policy means - // the host is treated as ServiceType.custom with custom rules. - for (final entry in _customOverrides.entries) { - if (host == entry.key || host.endsWith('.${entry.key}')) { - return ServiceType.custom; - } - } - for (final entry in _hostPatterns.entries) { if (host == entry.key || host.endsWith('.${entry.key}')) { return entry.value; @@ -239,29 +220,6 @@ class ServicePolicyResolver { static ServicePolicy resolveByType(ServiceType type) => _policies[type] ?? const ServicePolicy(); - /// Register a custom policy override for a host pattern. - /// - /// Use this to configure self-hosted services: - /// ```dart - /// ServicePolicyResolver.registerCustomPolicy( - /// 'tiles.myserver.com', - /// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20), - /// ); - /// ``` - static void registerCustomPolicy(String hostPattern, ServicePolicy policy) { - _customOverrides[hostPattern] = policy; - } - - /// Remove a custom policy override. - static void removeCustomPolicy(String hostPattern) { - _customOverrides.remove(hostPattern); - } - - /// Clear all custom policy overrides (useful for testing). - static void clearCustomPolicies() { - _customOverrides.clear(); - } - /// Extract the host from a URL or URL template. static String? _extractHost(String url) { // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' @@ -283,6 +241,95 @@ class ServicePolicyResolver { } } +/// How the retry/fallback engine should handle an error. +enum ErrorDisposition { + /// Stop immediately. Don't retry, don't try fallback. (400, business logic) + abort, + /// Don't retry same server, but DO try fallback endpoint. (429 rate limit) + fallback, + /// Retry with backoff against same server, then fallback if exhausted. (5xx, network) + retry, +} + +/// Retry and fallback configuration for resilient HTTP services. +class ResiliencePolicy { + final int maxRetries; + final Duration httpTimeout; + final Duration _retryBackoffBase; + final int _retryBackoffMaxMs; + + const ResiliencePolicy({ + this.maxRetries = 1, + this.httpTimeout = const Duration(seconds: 30), + Duration retryBackoffBase = const Duration(milliseconds: 200), + int retryBackoffMaxMs = 5000, + }) : _retryBackoffBase = retryBackoffBase, + _retryBackoffMaxMs = retryBackoffMaxMs; + + Duration retryDelay(int attempt) { + final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, _retryBackoffMaxMs); + return Duration(milliseconds: ms); + } +} + +/// Execute a request with retry and fallback logic. +/// +/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. +/// 2. On each failure, calls [classifyError] to determine disposition: +/// - [ErrorDisposition.abort]: rethrows immediately +/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available) +/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted +/// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error, +/// repeats the retry loop against the fallback. +Future executeWithFallback({ + required String primaryUrl, + required String? fallbackUrl, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy policy = const ResiliencePolicy(), +}) async { + try { + return await _executeWithRetries(primaryUrl, execute, classifyError, policy); + } catch (e) { + // _executeWithRetries rethrows abort/fallback/exhausted-retry errors. + // Re-classify only to distinguish abort (which must not fall back) from + // fallback/retry-exhausted (which should). This is the one intentional + // re-classification — _executeWithRetries cannot short-circuit past the + // outer try/catch. + if (classifyError(e) == ErrorDisposition.abort) rethrow; + if (fallbackUrl == null) rethrow; + debugPrint('[Resilience] Primary failed ($e), trying fallback'); + return _executeWithRetries(fallbackUrl, execute, classifyError, policy); + } +} + +Future _executeWithRetries( + String url, + Future Function(String url) execute, + ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy policy, +) async { + for (int attempt = 0; attempt <= policy.maxRetries; attempt++) { + try { + return await execute(url); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback + // disposition == retry + if (attempt < policy.maxRetries) { + final delay = policy.retryDelay(attempt); + debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms'); + await Future.delayed(delay); + continue; + } + rethrow; // retries exhausted, let caller try fallback + } + } + throw StateError('Unreachable'); // loop always returns or throws +} + /// Reusable per-service rate limiter and concurrency controller. /// /// Enforces the rate limits and concurrency constraints defined in each diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index feb090d6..b257a31a 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier { _calculateRoute(); } - /// Calculate route using alprwatch + /// Calculate route via RoutingService (primary + fallback endpoints). void _calculateRoute() { if (_routeStart == null || _routeEnd == null) return; - debugPrint('[NavigationState] Calculating route with alprwatch...'); + debugPrint('[NavigationState] Calculating route...'); _isCalculating = true; _routingError = null; notifyListeners(); @@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier { _showingOverview = true; _provisionalPinLocation = null; // Hide provisional pin - debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}'); + debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}'); notifyListeners(); }).catchError((error) { diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index f7704ecc..b862a2a1 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/models/node_profile.dart'; import 'package:deflockapp/services/overpass_service.dart'; +import 'package:deflockapp/services/service_policy.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -36,6 +37,7 @@ void main() { setUp(() { mockClient = MockHttpClient(); + // Initialize OverpassService with a mock HTTP client for testing service = OverpassService(client: mockClient); }); @@ -246,9 +248,9 @@ void main() { stubErrorResponse( 400, 'Error: too many nodes (limit is 50000) in query'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -256,9 +258,9 @@ void main() { test('response with "timeout" throws NodeLimitError', () async { stubErrorResponse(400, 'runtime error: timeout in query execution'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -267,9 +269,9 @@ void main() { () async { stubErrorResponse(400, 'runtime limit exceeded'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -277,9 +279,9 @@ void main() { test('HTTP 429 throws RateLimitError', () async { stubErrorResponse(429, 'Too Many Requests'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -287,9 +289,9 @@ void main() { test('response with "rate limited" throws RateLimitError', () async { stubErrorResponse(503, 'You are rate limited'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -298,9 +300,9 @@ void main() { () async { stubErrorResponse(500, 'Internal Server Error'); - expect( + await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, maxRetries: 0), + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); @@ -313,4 +315,178 @@ void main() { verifyNever(() => mockClient.post(any(), body: any(named: 'body'))); }); }); + + group('fallback behavior', () { + test('falls back to overpass-api.de on NetworkError after retries', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Internal Server Error', 500); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)); + + expect(nodes, hasLength(1)); + // primary (1 attempt, 0 retries) + fallback (1 attempt) = 2 + expect(callCount, equals(2)); + }); + + test('does NOT fallback on NodeLimitError', () async { + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response( + 'Error: too many nodes (limit is 50000) in query', + 400, + )); + + await expectLater( + () => service.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + throwsA(isA()), + ); + + // Only one call — no fallback (abort disposition) + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(1); + }); + + test('RateLimitError triggers fallback without retrying primary', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Too Many Requests', 429); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + + expect(nodes, hasLength(1)); + // 1 primary (no retry on fallback disposition) + 1 fallback = 2 + expect(callCount, equals(2)); + }); + + test('primary fails then fallback also fails -> error propagated', () async { + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => + http.Response('Internal Server Error', 500)); + + await expectLater( + () => service.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + throwsA(isA()), + ); + + // primary + fallback + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(2); + }); + + test('does NOT fallback when using custom endpoint', () async { + final customService = OverpassService( + client: mockClient, + endpoint: 'https://custom.example.com/api/interpreter', + ); + + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((_) async => + http.Response('Internal Server Error', 500)); + + await expectLater( + () => customService.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + throwsA(isA()), + ); + + // Only one call - no fallback with custom endpoint + verify(() => mockClient.post(any(), body: any(named: 'body'))) + .called(1); + }); + + test('retries exhaust before fallback kicks in', () async { + int callCount = 0; + when(() => mockClient.post(any(), body: any(named: 'body'))) + .thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'overpass.deflock.org') { + return http.Response('Server Error', 500); + } + // Fallback succeeds + return http.Response( + jsonEncode({ + 'elements': [ + { + 'type': 'node', + 'id': 1, + 'lat': 38.9, + 'lon': -77.0, + 'tags': {'man_made': 'surveillance'}, + }, + ] + }), + 200, + ); + }); + + final nodes = await service.fetchNodes( + bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + + expect(nodes, hasLength(1)); + // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 + expect(callCount, equals(4)); + }); + }); + + group('default endpoints', () { + test('default endpoint is overpass.deflock.org', () { + expect(OverpassService.defaultEndpoint, + equals('https://overpass.deflock.org/api/interpreter')); + }); + + test('fallback endpoint is overpass-api.de', () { + expect(OverpassService.fallbackEndpoint, + equals('https://overpass-api.de/api/interpreter')); + }); + }); } diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index 757000e2..e26b25f1 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -41,6 +41,30 @@ void main() { AppState.instance = MockAppState(); }); + /// Helper: stub a successful routing response + void stubSuccessResponse() { + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [ + [-77.0, 38.9], + [-77.1, 39.0], + ], + 'distance': 1000.0, + 'duration': 600.0, + }, + }, + }), + 200, + )); + } + group('RoutingService', () { test('empty tags are filtered from request body', () async { // Profile with empty tag values (like builtin-flock has camera:mount: '') @@ -57,29 +81,7 @@ void main() { ]; when(() => mockAppState.enabledProfiles).thenReturn(profiles); - // Capture the request body - when(() => mockClient.post( - any(), - headers: any(named: 'headers'), - body: any(named: 'body'), - )).thenAnswer((invocation) async { - return http.Response( - json.encode({ - 'ok': true, - 'result': { - 'route': { - 'coordinates': [ - [-77.0, 38.9], - [-77.1, 39.0], - ], - 'distance': 1000.0, - 'duration': 600.0, - }, - }, - }), - 200, - ); - }); + stubSuccessResponse(); await service.calculateRoute(start: start, end: end); @@ -147,7 +149,7 @@ void main() { reasonPhrase: 'Bad Request', )); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -166,7 +168,7 @@ void main() { body: any(named: 'body'), )).thenThrow(http.ClientException('Connection refused')); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -176,7 +178,7 @@ void main() { ); }); - test('API-level error surfaces alprwatch message', () async { + test('API-level error surfaces message', () async { when(() => mockAppState.enabledProfiles).thenReturn([]); when(() => mockClient.post( @@ -191,7 +193,7 @@ void main() { 200, )); - expect( + await expectLater( () => service.calculateRoute(start: start, end: end), throwsA(isA().having( (e) => e.message, @@ -201,4 +203,299 @@ void main() { ); }); }); + + group('fallback behavior', () { + test('falls back to secondary on server error (500) after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Internal Server Error', 500, + reasonPhrase: 'Internal Server Error'); + } + // Fallback succeeds + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [ + [-77.0, 38.9], + [-77.1, 39.0], + ], + 'distance': 5000.0, + 'duration': 300.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(5000.0)); + // 2 primary attempts (1 + 1 retry) + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('falls back on 502 (GraphHopper unavailable) after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 100.0, + 'duration': 60.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(100.0)); + // 2 primary attempts + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('falls back on network error after retries', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + throw http.ClientException('Connection refused'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 100.0, + 'duration': 60.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(100.0)); + // 2 primary attempts + 1 fallback = 3 + expect(callCount, equals(3)); + }); + + test('429 triggers fallback without retrying primary', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + int callCount = 0; + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((invocation) async { + callCount++; + final uri = invocation.positionalArguments[0] as Uri; + if (uri.host == 'api.dontgetflocked.com') { + return http.Response('Too Many Requests', 429, + reasonPhrase: 'Too Many Requests'); + } + return http.Response( + json.encode({ + 'ok': true, + 'result': { + 'route': { + 'coordinates': [[-77.0, 38.9]], + 'distance': 200.0, + 'duration': 120.0, + }, + }, + }), + 200, + ); + }); + + final result = await service.calculateRoute(start: start, end: end); + expect(result.distanceMeters, equals(200.0)); + // 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2 + expect(callCount, equals(2)); + }); + + test('does NOT fallback on 400 (validation error)', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Bad Request: missing start', 400, + reasonPhrase: 'Bad Request')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 400)), + ); + + // Only one call — no retry, no fallback (abort disposition) + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + + test('does NOT fallback on 403 (all 4xx except 429 abort)', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Forbidden', 403, + reasonPhrase: 'Forbidden')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 403)), + ); + + // Only one call — no retry, no fallback (abort disposition) + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + + test('does NOT fallback on API-level business logic errors', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + json.encode({ + 'ok': false, + 'error': 'No route found', + }), + 200, + )); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.isApiError, 'isApiError', true)), + ); + + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + }); + + test('primary fails then fallback also fails -> error propagated', () async { + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Internal Server Error', 500, + reasonPhrase: 'Internal Server Error')); + + await expectLater( + () => service.calculateRoute(start: start, end: end), + throwsA(isA().having( + (e) => e.statusCode, 'statusCode', 500)), + ); + + // 2 primary attempts + 2 fallback attempts = 4 + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(4); + }); + + test('does NOT fallback when using custom baseUrl', () async { + final customService = RoutingService( + client: mockClient, + baseUrl: 'https://custom.example.com/route', + ); + + when(() => mockAppState.enabledProfiles).thenReturn([]); + + when(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response( + 'Service Unavailable', 503, + reasonPhrase: 'Service Unavailable')); + + await expectLater( + () => customService.calculateRoute(start: start, end: end), + throwsA(isA()), + ); + + // 2 attempts (1 + 1 retry), no fallback with custom URL + verify(() => mockClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(2); + }); + }); + + group('RoutingException', () { + test('statusCode is preserved', () { + const e = RoutingException('test', statusCode: 502); + expect(e.statusCode, 502); + expect(e.isApiError, false); + }); + + test('isApiError flag works', () { + const e = RoutingException('test', isApiError: true); + expect(e.isApiError, true); + expect(e.statusCode, isNull); + }); + }); } diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index bfe31e41..1243ab51 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -5,10 +5,6 @@ import 'package:deflockapp/services/service_policy.dart'; void main() { group('ServicePolicyResolver', () { - setUp(() { - ServicePolicyResolver.clearCustomPolicies(); - }); - group('resolveType', () { test('resolves OSM editing API from production URL', () { expect( @@ -200,63 +196,6 @@ void main() { }); }); - group('custom policy overrides', () { - test('custom override takes precedence over built-in', () { - ServicePolicyResolver.registerCustomPolicy( - 'overpass-api.de', - const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true), - ); - - final policy = ServicePolicyResolver.resolve( - 'https://overpass-api.de/api/interpreter', - ); - expect(policy.maxConcurrentRequests, 20); - }); - - test('custom policy for self-hosted tiles allows offline', () { - ServicePolicyResolver.registerCustomPolicy( - 'tiles.myserver.com', - const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16), - ); - - final policy = ServicePolicyResolver.resolve( - 'https://tiles.myserver.com/{z}/{x}/{y}.png', - ); - expect(policy.allowsOfflineDownload, true); - expect(policy.maxConcurrentRequests, 16); - }); - - test('removing custom override restores built-in policy', () { - ServicePolicyResolver.registerCustomPolicy( - 'overpass-api.de', - const ServicePolicy.custom(maxConcurrent: 20), - ); - expect( - ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, - 20, - ); - - ServicePolicyResolver.removeCustomPolicy('overpass-api.de'); - // Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere) - expect( - ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, - 0, - ); - }); - - test('clearCustomPolicies removes all overrides', () { - ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1)); - ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2)); - - ServicePolicyResolver.clearCustomPolicies(); - - // Both should now return custom (default) policy - expect( - ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests, - 8, // default custom maxConcurrent - ); - }); - }); }); group('ServiceRateLimiter', () { @@ -423,4 +362,197 @@ void main() { expect(policy.attributionUrl, 'https://example.com/license'); }); }); + + group('ResiliencePolicy', () { + test('retryDelay uses exponential backoff', () { + const policy = ResiliencePolicy( + retryBackoffBase: Duration(milliseconds: 100), + retryBackoffMaxMs: 2000, + ); + expect(policy.retryDelay(0), const Duration(milliseconds: 100)); + expect(policy.retryDelay(1), const Duration(milliseconds: 200)); + expect(policy.retryDelay(2), const Duration(milliseconds: 400)); + }); + + test('retryDelay clamps to max', () { + const policy = ResiliencePolicy( + retryBackoffBase: Duration(milliseconds: 1000), + retryBackoffMaxMs: 3000, + ); + expect(policy.retryDelay(0), const Duration(milliseconds: 1000)); + expect(policy.retryDelay(1), const Duration(milliseconds: 2000)); + expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped + expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped + }); + }); + + group('executeWithFallback', () { + const policy = ResiliencePolicy( + maxRetries: 2, + retryBackoffBase: Duration.zero, // no delay in tests + ); + + test('abort error stops immediately, no fallback', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + throw Exception('bad request'); + }, + classifyError: (_) => ErrorDisposition.abort, + policy: policy, + ), + throwsA(isA()), + ); + + expect(callCount, 1); // no retries, no fallback + }); + + test('fallback error skips retries, goes to fallback', () async { + final urlsSeen = []; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + urlsSeen.add(url); + if (url.contains('primary')) { + throw Exception('rate limited'); + } + return Future.value('ok from fallback'); + }, + classifyError: (_) => ErrorDisposition.fallback, + policy: policy, + ); + + expect(result, 'ok from fallback'); + // 1 primary (no retries) + 1 fallback = 2 + expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']); + }); + + test('retry error retries N times then falls back', () async { + final urlsSeen = []; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + urlsSeen.add(url); + if (url.contains('primary')) { + throw Exception('server error'); + } + return Future.value('ok from fallback'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'ok from fallback'); + // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 + expect(urlsSeen.where((u) => u.contains('primary')).length, 3); + expect(urlsSeen.where((u) => u.contains('fallback')).length, 1); + }); + + test('no fallback URL rethrows after retries', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: null, + execute: (url) { + callCount++; + throw Exception('server error'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ), + throwsA(isA()), + ); + + // 3 attempts (1 + 2 retries), then rethrow + expect(callCount, 3); + }); + + test('fallback disposition with no fallback URL rethrows immediately', () async { + int callCount = 0; + + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: null, + execute: (url) { + callCount++; + throw Exception('rate limited'); + }, + classifyError: (_) => ErrorDisposition.fallback, + policy: policy, + ), + throwsA(isA()), + ); + + // Only 1 attempt — fallback disposition skips retries, and no fallback URL + expect(callCount, 1); + }); + + test('both fail propagates last error', () async { + await expectLater( + () => executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + if (url.contains('fallback')) { + throw Exception('fallback also failed'); + } + throw Exception('primary failed'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('fallback also failed'))), + ); + }); + + test('success on first try returns immediately', () async { + int callCount = 0; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + return Future.value('success'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'success'); + expect(callCount, 1); + }); + + test('success after retry does not try fallback', () async { + int callCount = 0; + + final result = await executeWithFallback( + primaryUrl: 'https://primary.example.com', + fallbackUrl: 'https://fallback.example.com', + execute: (url) { + callCount++; + if (callCount == 1) throw Exception('transient'); + return Future.value('recovered'); + }, + classifyError: (_) => ErrorDisposition.retry, + policy: policy, + ); + + expect(result, 'recovered'); + expect(callCount, 2); // 1 fail + 1 success, no fallback + }); + }); } From ca7192d3ece449fbc448f21ecace8c570350b278 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 11 Mar 2026 23:19:26 -0600 Subject: [PATCH 2/3] Add changelog entry for retry/fallback feature Co-Authored-By: Claude Opus 4.6 --- assets/changelog.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/changelog.json b/assets/changelog.json index 889a68f8..c4d3718c 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,7 +1,8 @@ { "2.9.1": { "content": [ - "• When hitting node render limit, only render nodes closest to center of viewport." + "• When hitting node render limit, only render nodes closest to center of viewport.", + "• Moved to our own infrastructure for Overpass and routing services, with automatic fallback to public servers." ] }, "2.9.0": { From dd16efd95d25a26b451c4a7154777ec0ea97b604 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:12:51 -0600 Subject: [PATCH 3/3] Add multi-endpoint service registry with per-endpoint policy overrides Evolve from hard-coded primary/fallback endpoint pairs to a user-configurable service registry. Each endpoint carries its own name, URL, enabled state, and optional retry/timeout overrides. - ServiceEndpoint model with JSON serialization and sensible defaults - ServiceRegistry generic ordered list with SharedPreferences persistence - executeWithEndpointList() iterates enabled endpoints with per-endpoint policy - OverpassService and RoutingService accept List - SettingsState uses ServiceRegistry instances with migration from old format - API Endpoints UI section for managing endpoint registries Co-Authored-By: Claude Opus 4.6 --- lib/app_state.dart | 12 + lib/localizations/de.json | 18 +- lib/localizations/en.json | 18 +- lib/localizations/es.json | 18 +- lib/localizations/fr.json | 18 +- lib/localizations/it.json | 18 +- lib/localizations/nl.json | 18 +- lib/localizations/pl.json | 18 +- lib/localizations/pt.json | 18 +- lib/localizations/tr.json | 18 +- lib/localizations/uk.json | 18 +- lib/localizations/zh.json | 18 +- lib/models/service_endpoint.dart | 116 ++++++ lib/models/service_registry_entry.dart | 22 ++ lib/screens/advanced_settings_screen.dart | 3 + .../sections/api_endpoints_section.dart | 280 ++++++++++++++ lib/services/overpass_service.dart | 50 ++- lib/services/routing_service.dart | 32 +- lib/services/service_policy.dart | 65 +++- lib/state/service_registry.dart | 133 +++++++ lib/state/settings_state.dart | 89 ++++- pubspec.lock | 16 +- test/models/service_endpoint_test.dart | 83 ++++ test/services/overpass_service_test.dart | 39 +- test/services/routing_service_test.dart | 15 +- test/services/service_policy_test.dart | 207 ++++++++++ test/state/service_registry_test.dart | 362 ++++++++++++++++++ test/state/settings_state_test.dart | 155 ++++++++ 28 files changed, 1797 insertions(+), 80 deletions(-) create mode 100644 lib/models/service_endpoint.dart create mode 100644 lib/models/service_registry_entry.dart create mode 100644 lib/screens/settings/sections/api_endpoints_section.dart create mode 100644 lib/state/service_registry.dart create mode 100644 test/models/service_endpoint_test.dart create mode 100644 test/state/service_registry_test.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index a208d638..f71644ed 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -11,6 +11,7 @@ import 'models/operator_profile.dart'; import 'models/osm_node.dart'; import 'models/pending_upload.dart'; import 'models/suspected_location.dart'; +import 'models/service_endpoint.dart'; import 'models/tile_provider.dart'; import 'models/search_result.dart'; import 'services/offline_area_service.dart'; @@ -31,6 +32,7 @@ import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/search_state.dart'; import 'state/session_state.dart'; +import 'state/service_registry.dart'; import 'state/settings_state.dart'; import 'state/suspected_location_state.dart'; import 'state/upload_queue_state.dart'; @@ -167,6 +169,14 @@ class AppState extends ChangeNotifier { bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get isCheckingMessages => _messagesState.isChecking; + // API endpoint settings + List get routingEndpoints => _settingsState.routingEndpoints; + List get enabledRoutingEndpoints => _settingsState.enabledRoutingEndpoints; + List get overpassEndpoints => _settingsState.overpassEndpoints; + List get enabledOverpassEndpoints => _settingsState.enabledOverpassEndpoints; + ServiceRegistry get routingRegistry => _settingsState.routingRegistry; + ServiceRegistry get overpassRegistry => _settingsState.overpassRegistry; + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; @@ -753,6 +763,8 @@ class AppState extends ChangeNotifier { await _settingsState.setDistanceUnit(unit); } + // Endpoint registry methods are accessed via routingRegistry/overpassRegistry directly + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index cd9bdae6..482cdb5a 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -74,7 +74,23 @@ "advancedSettings": "Erweiterte Einstellungen", "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "proximityAlerts": "Näherungswarnungen", - "networkStatusIndicator": "Netzwerkstatus-Anzeige" + "networkStatusIndicator": "Netzwerkstatus-Anzeige", + "apiEndpoints": "API-Endpunkte", + "apiEndpointsDescription": "Prioritätsreihenfolge der API-Endpunkte konfigurieren, benutzerdefinierte Endpunkte hinzufügen oder auf Standard zurücksetzen.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knotenquelle", + "invalidUrl": "Geben Sie eine gültige HTTPS-URL ein", + "addEndpoint": "Endpunkt hinzufügen", + "editEndpoint": "Endpunkt bearbeiten", + "resetToDefaults": "Auf Standard zurücksetzen", + "endpointName": "Endpunktname", + "endpointUrl": "Endpunkt-URL", + "endpointMaxRetries": "Max. Wiederholungen", + "endpointTimeout": "Timeout (Sekunden)", + "endpointEnabled": "Aktiviert", + "noEnabledEndpoints": "Mindestens ein Endpunkt muss aktiviert bleiben", + "confirmResetEndpoints": "Alle Endpunkte auf Standardkonfiguration zurücksetzen?", + "builtInEndpoint": "Eingebaut" }, "proximityAlerts": { "getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 7f7023b4..53be06ca 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -111,7 +111,23 @@ "advancedSettings": "Advanced Settings", "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "proximityAlerts": "Proximity Alerts", - "networkStatusIndicator": "Network Status Indicator" + "networkStatusIndicator": "Network Status Indicator", + "apiEndpoints": "API Endpoints", + "apiEndpointsDescription": "Configure API endpoint priority order, add custom endpoints, or reset to defaults.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Node Source", + "invalidUrl": "Enter a valid HTTPS URL", + "addEndpoint": "Add Endpoint", + "editEndpoint": "Edit Endpoint", + "resetToDefaults": "Reset to Defaults", + "endpointName": "Endpoint Name", + "endpointUrl": "Endpoint URL", + "endpointMaxRetries": "Max Retries", + "endpointTimeout": "Timeout (seconds)", + "endpointEnabled": "Enabled", + "noEnabledEndpoints": "At least one endpoint must remain enabled", + "confirmResetEndpoints": "Reset all endpoints to their default configuration?", + "builtInEndpoint": "Built-in" }, "proximityAlerts": { "getNotified": "Get notified when approaching surveillance devices", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 423ca6d8..2da6400c 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -111,7 +111,23 @@ "advancedSettings": "Configuración Avanzada", "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "proximityAlerts": "Alertas de Proximidad", - "networkStatusIndicator": "Indicador de Estado de Red" + "networkStatusIndicator": "Indicador de Estado de Red", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar el orden de prioridad de los endpoints de API, agregar endpoints personalizados o restablecer valores predeterminados.", + "apiEndpointRouting": "Enrutamiento", + "apiEndpointNodeSource": "Fuente de nodos", + "invalidUrl": "Ingrese una URL HTTPS válida", + "addEndpoint": "Agregar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Restablecer Valores Predeterminados", + "endpointName": "Nombre del Endpoint", + "endpointUrl": "URL del Endpoint", + "endpointMaxRetries": "Máx. Reintentos", + "endpointTimeout": "Tiempo de espera (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Al menos un endpoint debe permanecer habilitado", + "confirmResetEndpoints": "¿Restablecer todos los endpoints a su configuración predeterminada?", + "builtInEndpoint": "Incorporado" }, "proximityAlerts": { "getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index b314a5b0..a975f126 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -111,7 +111,23 @@ "advancedSettings": "Paramètres Avancés", "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "proximityAlerts": "Alertes de Proximité", - "networkStatusIndicator": "Indicateur de Statut Réseau" + "networkStatusIndicator": "Indicateur de Statut Réseau", + "apiEndpoints": "Points d'accès API", + "apiEndpointsDescription": "Configurer l'ordre de priorité des points d'accès API, ajouter des points d'accès personnalisés ou réinitialiser aux valeurs par défaut.", + "apiEndpointRouting": "Routage", + "apiEndpointNodeSource": "Source des nœuds", + "invalidUrl": "Entrez une URL HTTPS valide", + "addEndpoint": "Ajouter un Point d'accès", + "editEndpoint": "Modifier le Point d'accès", + "resetToDefaults": "Réinitialiser aux Valeurs par Défaut", + "endpointName": "Nom du Point d'accès", + "endpointUrl": "URL du Point d'accès", + "endpointMaxRetries": "Max. Tentatives", + "endpointTimeout": "Délai d'attente (secondes)", + "endpointEnabled": "Activé", + "noEnabledEndpoints": "Au moins un point d'accès doit rester activé", + "confirmResetEndpoints": "Réinitialiser tous les points d'accès à leur configuration par défaut ?", + "builtInEndpoint": "Intégré" }, "proximityAlerts": { "getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6602d76c..7d536688 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -111,7 +111,23 @@ "advancedSettings": "Impostazioni Avanzate", "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "proximityAlerts": "Avvisi di Prossimità", - "networkStatusIndicator": "Indicatore di Stato di Rete" + "networkStatusIndicator": "Indicatore di Stato di Rete", + "apiEndpoints": "Endpoint API", + "apiEndpointsDescription": "Configura l'ordine di priorità degli endpoint API, aggiungi endpoint personalizzati o ripristina i valori predefiniti.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Fonte dei nodi", + "invalidUrl": "Inserisci un URL HTTPS valido", + "addEndpoint": "Aggiungi Endpoint", + "editEndpoint": "Modifica Endpoint", + "resetToDefaults": "Ripristina Predefiniti", + "endpointName": "Nome Endpoint", + "endpointUrl": "URL Endpoint", + "endpointMaxRetries": "Max Tentativi", + "endpointTimeout": "Timeout (secondi)", + "endpointEnabled": "Abilitato", + "noEnabledEndpoints": "Almeno un endpoint deve rimanere abilitato", + "confirmResetEndpoints": "Ripristinare tutti gli endpoint alla configurazione predefinita?", + "builtInEndpoint": "Integrato" }, "proximityAlerts": { "getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index f9d0cd55..48126a66 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -111,7 +111,23 @@ "advancedSettings": "Geavanceerde Instellingen", "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "proximityAlerts": "Nabijheids Waarschuwingen", - "networkStatusIndicator": "Netwerk Status Indicator" + "networkStatusIndicator": "Netwerk Status Indicator", + "apiEndpoints": "API-eindpunten", + "apiEndpointsDescription": "Configureer de prioriteitsvolgorde van API-eindpunten, voeg aangepaste eindpunten toe of reset naar standaard.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knooppuntbron", + "invalidUrl": "Voer een geldige HTTPS-URL in", + "addEndpoint": "Eindpunt Toevoegen", + "editEndpoint": "Eindpunt Bewerken", + "resetToDefaults": "Standaardwaarden Herstellen", + "endpointName": "Eindpuntnaam", + "endpointUrl": "Eindpunt-URL", + "endpointMaxRetries": "Max. Pogingen", + "endpointTimeout": "Timeout (seconden)", + "endpointEnabled": "Ingeschakeld", + "noEnabledEndpoints": "Minstens één eindpunt moet ingeschakeld blijven", + "confirmResetEndpoints": "Alle eindpunten terugzetten naar standaardconfiguratie?", + "builtInEndpoint": "Ingebouwd" }, "proximityAlerts": { "getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 76fc22b4..a892c1e7 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -111,7 +111,23 @@ "advancedSettings": "Ustawienia Zaawansowane", "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "proximityAlerts": "Alerty Bliskości", - "networkStatusIndicator": "Wskaźnik Stanu Sieci" + "networkStatusIndicator": "Wskaźnik Stanu Sieci", + "apiEndpoints": "Punkty końcowe API", + "apiEndpointsDescription": "Skonfiguruj kolejność priorytetów punktów końcowych API, dodaj niestandardowe punkty końcowe lub przywróć wartości domyślne.", + "apiEndpointRouting": "Trasowanie", + "apiEndpointNodeSource": "Źródło węzłów", + "invalidUrl": "Wprowadź prawidłowy URL HTTPS", + "addEndpoint": "Dodaj Punkt Końcowy", + "editEndpoint": "Edytuj Punkt Końcowy", + "resetToDefaults": "Przywróć Domyślne", + "endpointName": "Nazwa Punktu Końcowego", + "endpointUrl": "URL Punktu Końcowego", + "endpointMaxRetries": "Maks. Powtórzeń", + "endpointTimeout": "Limit czasu (sekundy)", + "endpointEnabled": "Włączony", + "noEnabledEndpoints": "Co najmniej jeden punkt końcowy musi pozostać włączony", + "confirmResetEndpoints": "Przywrócić wszystkie punkty końcowe do konfiguracji domyślnej?", + "builtInEndpoint": "Wbudowany" }, "proximityAlerts": { "getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 8366a549..49dd5827 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -111,7 +111,23 @@ "advancedSettings": "Configurações Avançadas", "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "proximityAlerts": "Alertas de Proximidade", - "networkStatusIndicator": "Indicador de Status de Rede" + "networkStatusIndicator": "Indicador de Status de Rede", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar a ordem de prioridade dos endpoints de API, adicionar endpoints personalizados ou redefinir para os padrões.", + "apiEndpointRouting": "Roteamento", + "apiEndpointNodeSource": "Fonte de nós", + "invalidUrl": "Insira um URL HTTPS válido", + "addEndpoint": "Adicionar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Redefinir para Padrões", + "endpointName": "Nome do Endpoint", + "endpointUrl": "URL do Endpoint", + "endpointMaxRetries": "Máx. Tentativas", + "endpointTimeout": "Tempo limite (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Pelo menos um endpoint deve permanecer habilitado", + "confirmResetEndpoints": "Redefinir todos os endpoints para a configuração padrão?", + "builtInEndpoint": "Integrado" }, "proximityAlerts": { "getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 06fc9adf..6f5b4276 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -111,7 +111,23 @@ "advancedSettings": "Gelişmiş Ayarlar", "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "proximityAlerts": "Yakınlık Uyarıları", - "networkStatusIndicator": "Ağ Durumu Göstergesi" + "networkStatusIndicator": "Ağ Durumu Göstergesi", + "apiEndpoints": "API Uç Noktaları", + "apiEndpointsDescription": "API uç noktası öncelik sırasını yapılandırın, özel uç noktalar ekleyin veya varsayılanlara sıfırlayın.", + "apiEndpointRouting": "Yönlendirme", + "apiEndpointNodeSource": "Düğüm kaynağı", + "invalidUrl": "Geçerli bir HTTPS URL'si girin", + "addEndpoint": "Uç Nokta Ekle", + "editEndpoint": "Uç Noktayı Düzenle", + "resetToDefaults": "Varsayılanlara Sıfırla", + "endpointName": "Uç Nokta Adı", + "endpointUrl": "Uç Nokta URL'si", + "endpointMaxRetries": "Maks. Yeniden Deneme", + "endpointTimeout": "Zaman Aşımı (saniye)", + "endpointEnabled": "Etkin", + "noEnabledEndpoints": "En az bir uç nokta etkin kalmalıdır", + "confirmResetEndpoints": "Tüm uç noktalar varsayılan yapılandırmaya sıfırlansın mı?", + "builtInEndpoint": "Yerleşik" }, "proximityAlerts": { "getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index 499f1a24..5e3b82ef 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -111,7 +111,23 @@ "advancedSettings": "Розширені Налаштування", "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "proximityAlerts": "Сповіщення Про Близькість", - "networkStatusIndicator": "Індикатор Стану Мережі" + "networkStatusIndicator": "Індикатор Стану Мережі", + "apiEndpoints": "Кінцеві точки API", + "apiEndpointsDescription": "Налаштувати порядок пріоритету кінцевих точок API, додати власні кінцеві точки або скинути до стандартних.", + "apiEndpointRouting": "Маршрутизація", + "apiEndpointNodeSource": "Джерело вузлів", + "invalidUrl": "Введіть дійсну HTTPS URL-адресу", + "addEndpoint": "Додати Кінцеву Точку", + "editEndpoint": "Редагувати Кінцеву Точку", + "resetToDefaults": "Скинути до Стандартних", + "endpointName": "Назва Кінцевої Точки", + "endpointUrl": "URL Кінцевої Точки", + "endpointMaxRetries": "Макс. Спроб", + "endpointTimeout": "Тайм-аут (секунди)", + "endpointEnabled": "Увімкнено", + "noEnabledEndpoints": "Принаймні одна кінцева точка повинна залишатися увімкненою", + "confirmResetEndpoints": "Скинути всі кінцеві точки до стандартної конфігурації?", + "builtInEndpoint": "Вбудована" }, "proximityAlerts": { "getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 00695588..409e972d 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -111,7 +111,23 @@ "advancedSettings": "高级设置", "advancedSettingsSubtitle": "性能、警报和地图提供商设置", "proximityAlerts": "邻近警报", - "networkStatusIndicator": "网络状态指示器" + "networkStatusIndicator": "网络状态指示器", + "apiEndpoints": "API 端点", + "apiEndpointsDescription": "配置 API 端点优先级顺序,添加自定义端点或重置为默认值。", + "apiEndpointRouting": "路由", + "apiEndpointNodeSource": "节点来源", + "invalidUrl": "请输入有效的 HTTPS URL", + "addEndpoint": "添加端点", + "editEndpoint": "编辑端点", + "resetToDefaults": "重置为默认值", + "endpointName": "端点名称", + "endpointUrl": "端点 URL", + "endpointMaxRetries": "最大重试次数", + "endpointTimeout": "超时时间(秒)", + "endpointEnabled": "已启用", + "noEnabledEndpoints": "至少需要保留一个已启用的端点", + "confirmResetEndpoints": "将所有端点重置为默认配置?", + "builtInEndpoint": "内置" }, "proximityAlerts": { "getNotified": "接近监控设备时接收通知", diff --git a/lib/models/service_endpoint.dart b/lib/models/service_endpoint.dart new file mode 100644 index 00000000..1b94dd79 --- /dev/null +++ b/lib/models/service_endpoint.dart @@ -0,0 +1,116 @@ +import 'service_registry_entry.dart'; + +/// A configurable API endpoint with optional resilience overrides. +/// +/// Used by [RoutingService] and [OverpassService] as entries in +/// their priority-ordered endpoint lists. +class ServiceEndpoint implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + + /// The endpoint URL (must be HTTPS). + final String url; + + @override + final bool enabled; + @override + final bool isBuiltIn; + + /// Override the service's default max retry count. Null = use default. + final int? maxRetries; + + /// Override the service's default HTTP timeout in seconds. Null = use default. + final int? timeoutSeconds; + + const ServiceEndpoint({ + required this.id, + required this.name, + required this.url, + this.enabled = true, + this.isBuiltIn = false, + this.maxRetries, + this.timeoutSeconds, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + if (maxRetries != null) 'maxRetries': maxRetries, + if (timeoutSeconds != null) 'timeoutSeconds': timeoutSeconds, + }; + + static ServiceEndpoint fromJson(Map json) => ServiceEndpoint( + id: json['id'] as String, + name: json['name'] as String, + url: json['url'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + maxRetries: json['maxRetries'] as int?, + timeoutSeconds: json['timeoutSeconds'] as int?, + ); + + ServiceEndpoint copyWith({ + String? id, + String? name, + String? url, + bool? enabled, + bool? isBuiltIn, + int? maxRetries, + int? timeoutSeconds, + }) => ServiceEndpoint( + id: id ?? this.id, + name: name ?? this.name, + url: url ?? this.url, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn ?? this.isBuiltIn, + maxRetries: maxRetries ?? this.maxRetries, + timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ServiceEndpoint && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Default routing endpoints. +class DefaultServiceEndpoints { + static List routing() => const [ + ServiceEndpoint( + id: 'routing-deflock', + name: 'Deflock Primary', + url: 'https://api.dontgetflocked.com/api/v1/deflock/directions', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'routing-alprwatch', + name: 'ALPRWatch Fallback', + url: 'https://alprwatch.org/api/v1/deflock/directions', + isBuiltIn: true, + ), + ]; + + static List overpass() => const [ + ServiceEndpoint( + id: 'overpass-deflock', + name: 'Deflock Node Source', + url: 'https://overpass.deflock.org/api/interpreter', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'overpass-public', + name: 'Public Overpass', + url: 'https://overpass-api.de/api/interpreter', + isBuiltIn: true, + ), + ]; +} diff --git a/lib/models/service_registry_entry.dart b/lib/models/service_registry_entry.dart new file mode 100644 index 00000000..db671b75 --- /dev/null +++ b/lib/models/service_registry_entry.dart @@ -0,0 +1,22 @@ +/// Shared interface for entries managed by a [ServiceRegistry]. +/// +/// Both [ServiceEndpoint] and (in a future PR) [TileType] implement this, +/// enabling generic list management, persistence, and UI components. +abstract interface class ServiceRegistryEntry { + /// Unique identifier for this entry. + String get id; + + /// Human-readable display name. + String get name; + + /// Whether this entry is active. Disabled entries are skipped by + /// the resilience engine and hidden from primary selection UI. + bool get enabled; + + /// Whether this entry was provided by the app (built-in default). + /// Built-in entries cannot be deleted in production builds. + bool get isBuiltIn; + + /// Serialize to JSON for SharedPreferences persistence. + Map toJson(); +} diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index b5630a44..ffc78a30 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'settings/sections/api_endpoints_section.dart'; import 'settings/sections/max_nodes_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/suspected_locations_section.dart'; @@ -35,6 +36,8 @@ class AdvancedSettingsScreen extends StatelessWidget { // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled // Divider(), TileProviderSection(), + Divider(), + ApiEndpointsSection(), ], ), ), diff --git a/lib/screens/settings/sections/api_endpoints_section.dart b/lib/screens/settings/sections/api_endpoints_section.dart new file mode 100644 index 00000000..da4cc510 --- /dev/null +++ b/lib/screens/settings/sections/api_endpoints_section.dart @@ -0,0 +1,280 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../app_state.dart'; +import '../../../models/service_endpoint.dart'; +import '../../../state/service_registry.dart'; +import '../../../services/localization_service.dart'; + +class ApiEndpointsSection extends StatelessWidget { + const ApiEndpointsSection({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Consumer( + builder: (context, appState, _) { + final loc = LocalizationService.instance; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.t('settings.apiEndpoints'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + loc.t('settings.apiEndpointsDescription'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointRouting'), + registry: appState.routingRegistry, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointNodeSource'), + registry: appState.overpassRegistry, + ), + ], + ); + }, + ), + ); + } +} + +class _EndpointRegistryList extends StatelessWidget { + final String label; + final ServiceRegistry registry; + + const _EndpointRegistryList({ + required this.label, + required this.registry, + }); + + bool _isValidHttpsUrl(String url) { + if (url.isEmpty) return false; + final uri = Uri.tryParse(url); + return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty; + } + + @override + Widget build(BuildContext context) { + final loc = LocalizationService.instance; + final entries = registry.entries; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + )), + const SizedBox(height: 8), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: entries.length, + onReorder: (oldIndex, newIndex) { + registry.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final endpoint = entries[index]; + return _EndpointTile( + key: ValueKey(endpoint.id), + endpoint: endpoint, + index: index, + registry: registry, + canDelete: !endpoint.isBuiltIn || kDebugMode, + onToggle: (enabled) { + // Prevent disabling the last enabled endpoint + if (!enabled && registry.enabledEntries.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(loc.t('settings.noEnabledEndpoints'))), + ); + return; + } + registry.addOrUpdate(endpoint.copyWith(enabled: enabled)); + }, + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), + label: Text(loc.t('settings.addEndpoint')), + onPressed: () => _showAddDialog(context), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.restore, size: 18), + label: Text(loc.t('settings.resetToDefaults')), + onPressed: () => _showResetDialog(context), + ), + ], + ), + ], + ); + } + + void _showAddDialog(BuildContext context) { + final loc = LocalizationService.instance; + final nameController = TextEditingController(); + final urlController = TextEditingController(); + String? urlError; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(loc.t('settings.addEndpoint')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointName'), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointUrl'), + border: const OutlineInputBorder(), + errorText: urlError, + hintText: 'https://', + ), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + final url = urlController.text.trim(); + final name = nameController.text.trim(); + if (!_isValidHttpsUrl(url)) { + setState(() => urlError = loc.t('settings.invalidUrl')); + return; + } + if (name.isEmpty) return; + final id = 'custom-${DateTime.now().millisecondsSinceEpoch}'; + registry.addOrUpdate(ServiceEndpoint( + id: id, + name: name, + url: url, + )); + Navigator.pop(context); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ), + ); + } + + void _showResetDialog(BuildContext context) { + final loc = LocalizationService.instance; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(loc.t('settings.resetToDefaults')), + content: Text(loc.t('settings.confirmResetEndpoints')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + registry.resetToDefaults(); + Navigator.pop(dialogContext); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + final ServiceEndpoint endpoint; + final int index; + final ServiceRegistry registry; + final bool canDelete; + final void Function(bool enabled) onToggle; + + const _EndpointTile({ + super.key, + required this.endpoint, + required this.index, + required this.registry, + required this.canDelete, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 20), + ), + title: Text( + endpoint.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: endpoint.enabled ? null : theme.disabledColor, + ), + ), + subtitle: Text( + endpoint.url, + style: theme.textTheme.bodySmall?.copyWith( + color: endpoint.enabled ? theme.hintColor : theme.disabledColor, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: endpoint.enabled, + onChanged: onToggle, + ), + if (canDelete) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () { + try { + registry.delete(endpoint.id); + } on StateError catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 05b8c186..0b882917 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -6,6 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import '../models/node_profile.dart'; import '../models/osm_node.dart'; +import '../models/service_endpoint.dart'; +import '../app_state.dart'; import '../dev_config.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -21,48 +23,60 @@ class OverpassService { ); final http.Client _client; - /// Optional override endpoint. When null, uses [defaultEndpoint]. - final String? _endpointOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - OverpassService({http.Client? client, String? endpoint}) + OverpassService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _endpointOverride = endpoint; + _endpointsOverride = endpoints; - /// Resolve the primary endpoint: constructor override or default. - String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledOverpassEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized (e.g., in tests) + } + return DefaultServiceEndpoints.overpass(); + } /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ required LatLngBounds bounds, required List profiles, - ResiliencePolicy? policy, + int? maxRetries, }) async { if (profiles.isEmpty) return []; final query = _buildQuery(bounds, profiles); - final endpoint = _primaryEndpoint; - final canFallback = _endpointOverride == null; - final effectivePolicy = policy ?? _policy; - - return executeWithFallback>( - primaryUrl: endpoint, - fallbackUrl: canFallback ? fallbackEndpoint : null, - execute: (url) => _attemptFetch(url, query, effectivePolicy), + + final effectivePolicy = maxRetries != null + ? ResiliencePolicy( + maxRetries: maxRetries, + httpTimeout: _policy.httpTimeout, + ) + : _policy; + + return executeWithEndpointList>( + endpoints: _endpoints, + execute: (url) => _attemptFetch(url, query), classifyError: _classifyError, - policy: effectivePolicy, + defaultPolicy: effectivePolicy, ); } /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). - Future> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async { + Future> _attemptFetch(String endpoint, String query) async { debugPrint('[OverpassService] POST $endpoint'); try { final response = await _client.post( Uri.parse(endpoint), body: {'data': query}, - ).timeout(policy.httpTimeout); + ).timeout(_policy.httpTimeout); if (response.statusCode == 200) { return _parseResponse(response.body); diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 072a54af..c8ae5a7c 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; +import '../models/service_endpoint.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -34,17 +35,26 @@ class RoutingService { ); final http.Client _client; - /// Optional override URL. When null, uses [defaultUrl]. - final String? _baseUrlOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - RoutingService({http.Client? client, String? baseUrl}) + RoutingService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _baseUrlOverride = baseUrl; + _endpointsOverride = endpoints; void close() => _client.close(); - /// Resolve the primary URL to use: constructor override or default. - String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledRoutingEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized yet (e.g., in tests) + } + return DefaultServiceEndpoints.routing(); + } // Calculate route between two points Future calculateRoute({ @@ -81,15 +91,11 @@ class RoutingService { 'show_exclusion_zone': false, }; - final primaryUrl = _primaryUrl; - final canFallback = _baseUrlOverride == null; - - return executeWithFallback( - primaryUrl: primaryUrl, - fallbackUrl: canFallback ? fallbackUrl : null, + return executeWithEndpointList( + endpoints: _endpoints, execute: (url) => _postRoute(url, params), classifyError: _classifyError, - policy: _policy, + defaultPolicy: _policy, ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 8fd66cf2..d57a8e9c 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/service_endpoint.dart'; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. @@ -255,24 +256,71 @@ enum ErrorDisposition { class ResiliencePolicy { final int maxRetries; final Duration httpTimeout; - final Duration _retryBackoffBase; - final int _retryBackoffMaxMs; + final Duration retryBackoffBase; + final int retryBackoffMaxMs; const ResiliencePolicy({ this.maxRetries = 1, this.httpTimeout = const Duration(seconds: 30), - Duration retryBackoffBase = const Duration(milliseconds: 200), - int retryBackoffMaxMs = 5000, - }) : _retryBackoffBase = retryBackoffBase, - _retryBackoffMaxMs = retryBackoffMaxMs; + this.retryBackoffBase = const Duration(milliseconds: 200), + this.retryBackoffMaxMs = 5000, + }); Duration retryDelay(int attempt) { - final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) - .clamp(0, _retryBackoffMaxMs); + final ms = (retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, retryBackoffMaxMs); return Duration(milliseconds: ms); } } +/// Execute a request against an ordered list of endpoints with retry and fallback. +/// +/// Tries each enabled endpoint in list order. For each endpoint: +/// 1. Applies per-endpoint policy overrides (maxRetries, timeout) over [defaultPolicy] +/// 2. Retries on [ErrorDisposition.retry] errors up to the effective maxRetries +/// 3. On [ErrorDisposition.fallback], skips remaining retries and moves to next endpoint +/// 4. On [ErrorDisposition.abort], rethrows immediately (no further endpoints tried) +/// +/// Throws [StateError] if no endpoints are enabled. +/// Rethrows the last error if all endpoints are exhausted. +Future executeWithEndpointList({ + required List endpoints, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy defaultPolicy = const ResiliencePolicy(), +}) async { + final enabled = endpoints.where((e) => e.enabled).toList(growable: false); + if (enabled.isEmpty) { + throw StateError('No enabled endpoints configured'); + } + + Object? lastError; + for (final endpoint in enabled) { + final effectivePolicy = ResiliencePolicy( + maxRetries: endpoint.maxRetries ?? defaultPolicy.maxRetries, + httpTimeout: endpoint.timeoutSeconds != null + ? Duration(seconds: endpoint.timeoutSeconds!) + : defaultPolicy.httpTimeout, + retryBackoffBase: defaultPolicy.retryBackoffBase, + retryBackoffMaxMs: defaultPolicy.retryBackoffMaxMs, + ); + try { + return await _executeWithRetries( + endpoint.url, + execute, + classifyError, + effectivePolicy, + ); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + lastError = e; + debugPrint('[Resilience] Endpoint ${endpoint.name} failed ($e), trying next'); + } + } + throw lastError!; // All endpoints exhausted +} + /// Execute a request with retry and fallback logic. /// /// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. @@ -282,6 +330,7 @@ class ResiliencePolicy { /// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted /// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error, /// repeats the retry loop against the fallback. +@Deprecated('Use executeWithEndpointList instead') Future executeWithFallback({ required String primaryUrl, required String? fallbackUrl, diff --git a/lib/state/service_registry.dart b/lib/state/service_registry.dart new file mode 100644 index 00000000..e2d74e26 --- /dev/null +++ b/lib/state/service_registry.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/service_registry_entry.dart'; + +/// Generic list manager for [ServiceRegistryEntry] subtypes. +/// +/// Handles persistence (JSON in SharedPreferences), CRUD operations, +/// reordering, default management, and change notification. +class ServiceRegistry { + /// SharedPreferences key for this registry's data. + final String prefsKey; + + /// Deserialize a JSON map to a registry entry. + final T Function(Map) fromJson; + + /// Factory for creating the default entries (built-in endpoints/tiles). + final List Function() createDefaults; + + /// Called after every mutation to notify the owning state object. + final VoidCallback onChanged; + + List _entries = []; + SharedPreferences? _prefs; + + ServiceRegistry({ + required this.prefsKey, + required this.fromJson, + required this.createDefaults, + required this.onChanged, + }); + + /// Unmodifiable view of all entries in priority order. + List get entries => List.unmodifiable(_entries); + + /// Only entries with [enabled] == true, in priority order. + List get enabledEntries => + _entries.where((e) => e.enabled).toList(growable: false); + + /// Load from SharedPreferences; initialize with defaults if absent or corrupt. + Future load(SharedPreferences prefs) async { + _prefs = prefs; + if (prefs.containsKey(prefsKey)) { + try { + final json = jsonDecode(prefs.getString(prefsKey)!) as List; + _entries = json + .map((j) => fromJson(j as Map)) + .toList(); + await _addMissingDefaults(); + return; + } catch (e) { + debugPrint('[ServiceRegistry] Error loading $prefsKey: $e'); + } + } + _entries = List.of(createDefaults()); + await _save(); + } + + /// Add a new entry or update an existing one (matched by id). + /// New entries are appended; existing entries are replaced in-place. + Future addOrUpdate(T entry) async { + final index = _entries.indexWhere((e) => e.id == entry.id); + if (index >= 0) { + _entries[index] = entry; + } else { + _entries.add(entry); + } + await _save(); + onChanged(); + } + + /// Remove an entry by id. + /// Throws [StateError] if this would empty the list. + /// Throws [UnsupportedError] if the entry is built-in and we're in release mode. + Future delete(String id) async { + final index = _entries.indexWhere((e) => e.id == id); + if (index < 0) return; + if (_entries.length <= 1) { + throw StateError('Cannot delete the last entry'); + } + if (_entries[index].isBuiltIn && !kDebugMode) { + throw UnsupportedError('Cannot delete built-in entries in release mode'); + } + _entries.removeAt(index); + await _save(); + onChanged(); + } + + /// Move entry from [oldIndex] to [newIndex]. + Future reorder(int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; + // ReorderableListView convention: if moving down, newIndex is +1 + final adjustedNew = newIndex > oldIndex ? newIndex - 1 : newIndex; + final entry = _entries.removeAt(oldIndex); + _entries.insert(adjustedNew, entry); + await _save(); + onChanged(); + } + + /// Replace all entries with defaults. + Future resetToDefaults() async { + _entries = List.of(createDefaults()); + await _save(); + onChanged(); + } + + /// Find an entry by id, or null if not found. + T? findById(String id) { + for (final e in _entries) { + if (e.id == id) return e; + } + return null; + } + + /// Add any default entries that are missing from the current list. + Future _addMissingDefaults() async { + final existingIds = _entries.map((e) => e.id).toSet(); + final defaults = createDefaults(); + var changed = false; + for (final d in defaults) { + if (!existingIds.contains(d.id)) { + _entries.add(d); + changed = true; + } + } + if (changed) await _save(); + } + + Future _save() async { + final json = jsonEncode(_entries.map((e) => e.toJson()).toList()); + await _prefs!.setString(prefsKey, json); + } +} diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 7fbc6d09..5b726850 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:collection/collection.dart'; +import '../models/service_endpoint.dart'; import '../models/tile_provider.dart'; import '../dev_config.dart'; import '../keys.dart'; +import 'service_registry.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -38,6 +40,10 @@ class SettingsState extends ChangeNotifier { static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; + static const String _routingEndpointPrefsKey = 'routing_endpoint'; + static const String _overpassEndpointPrefsKey = 'overpass_endpoint'; + static const String _routingEndpointsPrefsKey = 'routing_endpoints'; + static const String _overpassEndpointsPrefsKey = 'overpass_endpoints'; bool _offlineMode = false; bool _pauseQueueProcessing = false; @@ -54,6 +60,20 @@ class SettingsState extends ChangeNotifier { int _navigationAvoidanceDistance = 250; // meters DistanceUnit _distanceUnit = DistanceUnit.metric; + late final ServiceRegistry _routingRegistry = ServiceRegistry( + prefsKey: _routingEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.routing, + onChanged: notifyListeners, + ); + + late final ServiceRegistry _overpassRegistry = ServiceRegistry( + prefsKey: _overpassEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.overpass, + onChanged: notifyListeners, + ); + // Getters bool get offlineMode => _offlineMode; bool get pauseQueueProcessing => _pauseQueueProcessing; @@ -68,7 +88,25 @@ class SettingsState extends ChangeNotifier { String get selectedTileTypeId => _selectedTileTypeId; int get navigationAvoidanceDistance => _navigationAvoidanceDistance; DistanceUnit get distanceUnit => _distanceUnit; - + + /// Ordered list of routing endpoints (priority order). + List get routingEndpoints => _routingRegistry.entries; + + /// Only enabled routing endpoints. + List get enabledRoutingEndpoints => _routingRegistry.enabledEntries; + + /// Ordered list of Overpass endpoints (priority order). + List get overpassEndpoints => _overpassRegistry.entries; + + /// Only enabled Overpass endpoints. + List get enabledOverpassEndpoints => _overpassRegistry.enabledEntries; + + /// The routing registry for direct UI manipulation. + ServiceRegistry get routingRegistry => _routingRegistry; + + /// The Overpass registry for direct UI manipulation. + ServiceRegistry get overpassRegistry => _overpassRegistry; + /// Get the currently selected tile type TileType? get selectedTileType { for (final provider in _tileProviders) { @@ -167,6 +205,22 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } + // Migrate old single-string endpoints to new registry format + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _routingEndpointPrefsKey, + registry: _routingRegistry, + ); + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _overpassEndpointPrefsKey, + registry: _overpassRegistry, + ); + + // Load endpoint registries + await _routingRegistry.load(prefs); + await _overpassRegistry.load(prefs); + // Load tile providers (default to built-in providers if none saved) await _loadTileProviders(prefs); @@ -405,4 +459,37 @@ class SettingsState extends ChangeNotifier { } } + /// Migrate old single-string endpoint to new registry list format. + Future _migrateOldEndpoint({ + required SharedPreferences prefs, + required String oldKey, + required ServiceRegistry registry, + }) async { + // Skip if new format already exists or old format doesn't + if (prefs.containsKey(registry.prefsKey) || !prefs.containsKey(oldKey)) return; + + final oldUrl = prefs.getString(oldKey); + if (oldUrl != null && oldUrl.isNotEmpty) { + // Create a list: custom endpoint first (highest priority), then defaults + final defaults = registry.createDefaults(); + // Check if custom URL matches any default + final isDefault = defaults.any((d) => d.url == oldUrl); + if (!isDefault) { + final customEntry = ServiceEndpoint( + id: 'custom-${oldKey.replaceAll('_', '-')}', + name: 'Custom', + url: oldUrl, + ); + final migrated = [customEntry, ...defaults]; + await prefs.setString( + registry.prefsKey, + jsonEncode(migrated.map((e) => e.toJson()).toList()), + ); + } + } + // Clean up old key + await prefs.remove(oldKey); + } + } + diff --git a/pubspec.lock b/pubspec.lock index b61873af..b3ea42f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -564,18 +564,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -929,10 +929,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: transitive description: diff --git a/test/models/service_endpoint_test.dart b/test/models/service_endpoint_test.dart new file mode 100644 index 00000000..92d68357 --- /dev/null +++ b/test/models/service_endpoint_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; + +void main() { + group('ServiceEndpoint', () { + test('toJson/fromJson round-trip preserves all fields', () { + const endpoint = ServiceEndpoint( + id: 'test', name: 'Test', url: 'https://example.com', + enabled: false, isBuiltIn: true, maxRetries: 5, timeoutSeconds: 10, + ); + final restored = ServiceEndpoint.fromJson(endpoint.toJson()); + expect(restored.id, endpoint.id); + expect(restored.name, endpoint.name); + expect(restored.url, endpoint.url); + expect(restored.enabled, endpoint.enabled); + expect(restored.isBuiltIn, endpoint.isBuiltIn); + expect(restored.maxRetries, endpoint.maxRetries); + expect(restored.timeoutSeconds, endpoint.timeoutSeconds); + }); + + test('fromJson uses defaults for missing optional fields', () { + final endpoint = ServiceEndpoint.fromJson({ + 'id': 'x', 'name': 'X', 'url': 'https://x.com', + }); + expect(endpoint.enabled, isTrue); + expect(endpoint.isBuiltIn, isFalse); + expect(endpoint.maxRetries, isNull); + expect(endpoint.timeoutSeconds, isNull); + }); + + test('copyWith replaces specified fields only', () { + const original = ServiceEndpoint( + id: 'a', name: 'A', url: 'https://a.com', + ); + final modified = original.copyWith(name: 'B', enabled: false); + expect(modified.id, 'a'); + expect(modified.name, 'B'); + expect(modified.url, 'https://a.com'); + expect(modified.enabled, isFalse); + }); + + test('equality is by id', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'x', name: 'B', url: 'https://b.com'); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different ids are not equal', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'y', name: 'A', url: 'https://a.com'); + expect(a, isNot(equals(b))); + }); + }); + + group('DefaultServiceEndpoints', () { + test('routing returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('overpass returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('routing endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints[0].url, contains('dontgetflocked.com')); + expect(endpoints[1].url, contains('alprwatch.org')); + }); + + test('overpass endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints[0].url, contains('overpass.deflock.org')); + expect(endpoints[1].url, contains('overpass-api.de')); + }); + }); +} diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index b862a2a1..16298d90 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -6,8 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/overpass_service.dart'; -import 'package:deflockapp/services/service_policy.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -37,8 +37,11 @@ void main() { setUp(() { mockClient = MockHttpClient(); - // Initialize OverpassService with a mock HTTP client for testing - service = OverpassService(client: mockClient); + // Use explicit endpoints to avoid AppState dependency in tests + service = OverpassService( + client: mockClient, + endpoints: DefaultServiceEndpoints.overpass(), + ); }); /// Helper: stub a successful Overpass response with the given elements. @@ -250,7 +253,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -260,7 +263,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -271,7 +274,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -281,7 +284,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -291,7 +294,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -302,7 +305,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -345,7 +348,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)); + bounds: bounds, profiles: profiles, maxRetries: 0); expect(nodes, hasLength(1)); // primary (1 attempt, 0 retries) + fallback (1 attempt) = 2 @@ -361,7 +364,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -398,7 +401,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + bounds: bounds, profiles: profiles, maxRetries: 2); expect(nodes, hasLength(1)); // 1 primary (no retry on fallback disposition) + 1 fallback = 2 @@ -412,7 +415,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -421,10 +424,12 @@ void main() { .called(2); }); - test('does NOT fallback when using custom endpoint', () async { + test('single custom endpoint does not fallback', () async { final customService = OverpassService( client: mockClient, - endpoint: 'https://custom.example.com/api/interpreter', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/api/interpreter'), + ], ); when(() => mockClient.post(any(), body: any(named: 'body'))) @@ -433,7 +438,7 @@ void main() { await expectLater( () => customService.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -470,7 +475,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + bounds: bounds, profiles: profiles, maxRetries: 2); expect(nodes, hasLength(1)); // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index e26b25f1..76da9bbb 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/routing_service.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -34,7 +35,11 @@ void main() { mockAppState = MockAppState(); AppState.instance = mockAppState; - service = RoutingService(client: mockClient); + // Use fixed endpoints so tests don't try to resolve AppState settings + service = RoutingService( + client: mockClient, + endpoints: DefaultServiceEndpoints.routing(), + ); }); tearDown(() { @@ -455,10 +460,12 @@ void main() { )).called(4); }); - test('does NOT fallback when using custom baseUrl', () async { + test('single custom endpoint does not fallback', () async { final customService = RoutingService( client: mockClient, - baseUrl: 'https://custom.example.com/route', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/route'), + ], ); when(() => mockAppState.enabledProfiles).thenReturn([]); @@ -476,7 +483,7 @@ void main() { throwsA(isA()), ); - // 2 attempts (1 + 1 retry), no fallback with custom URL + // 2 attempts (1 + 1 retry), no fallback with single endpoint verify(() => mockClient.post( any(), headers: any(named: 'headers'), diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index 1243ab51..75a99729 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -1,6 +1,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/service_policy.dart'; void main() { @@ -386,6 +387,212 @@ void main() { }); }); + group('executeWithEndpointList', () { + const defaultPolicy = ResiliencePolicy( + maxRetries: 1, + retryBackoffBase: Duration.zero, + ); + + test('throws StateError when no endpoints enabled', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', enabled: false), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ), + throwsA(isA()), + ); + }); + + test('succeeds with single enabled endpoint', () async { + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('tries next endpoint when retry exhausted', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('fail'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // endpoint A: 1 attempt + 1 retry = 2 calls, then endpoint B: 1 call + expect(urlsSeen.where((u) => u == 'https://a.com').length, 2); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('fallback disposition skips to next immediately', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('rate limited'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.fallback, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // Fallback: only 1 call to A (no retries), then 1 call to B + expect(urlsSeen.where((u) => u == 'https://a.com').length, 1); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('abort stops entire chain', () async { + final urlsSeen = []; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + throw Exception('validation error'); + }, + classifyError: (_) => ErrorDisposition.abort, + defaultPolicy: defaultPolicy, + ), + throwsA(isA()), + ); + + // Only A called once, B never called + expect(urlsSeen, ['https://a.com']); + }); + + test('per-endpoint maxRetries override', () async { + int callCount = 0; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', maxRetries: 0), + ], + execute: (url) { + callCount++; + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 5, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA()), + ); + + // maxRetries=0 means only 1 attempt (no retries) + expect(callCount, 1); + }); + + test('per-endpoint timeout override is used in effective policy', () async { + // We can't easily test the actual timeout behavior, but we can verify + // the endpoint is used successfully with the override + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', timeoutSeconds: 1), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('disabled endpoints skipped', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'disabled', name: 'Disabled', url: 'https://disabled.com', enabled: false), + ServiceEndpoint(id: 'enabled', name: 'Enabled', url: 'https://enabled.com'), + ], + execute: (url) { + urlsSeen.add(url); + return Future.value('ok'); + }, + classifyError: (_) => ErrorDisposition.retry, + ); + + expect(result, 'ok'); + expect(urlsSeen, ['https://enabled.com']); + }); + + test('all endpoints fail rethrows last error', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + if (url == 'https://b.com') throw Exception('b failed'); + throw Exception('a failed'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('b failed'), + )), + ); + }); + + test('3 endpoints, first 2 fail, third succeeds', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ServiceEndpoint(id: 'c', name: 'C', url: 'https://c.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://c.com') return Future.value('ok from c'); + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ); + + expect(result, 'ok from c'); + expect(urlsSeen, ['https://a.com', 'https://b.com', 'https://c.com']); + }); + }); + + // ignore: deprecated_member_use_from_same_package group('executeWithFallback', () { const policy = ResiliencePolicy( maxRetries: 2, diff --git a/test/state/service_registry_test.dart b/test/state/service_registry_test.dart new file mode 100644 index 00000000..6d14c3c4 --- /dev/null +++ b/test/state/service_registry_test.dart @@ -0,0 +1,362 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_registry_entry.dart'; +import 'package:deflockapp/state/service_registry.dart'; + +/// Simple test entry implementing ServiceRegistryEntry. +class TestEntry implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + @override + final bool enabled; + @override + final bool isBuiltIn; + + const TestEntry({ + required this.id, + required this.name, + this.enabled = true, + this.isBuiltIn = false, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + }; + + static TestEntry fromJson(Map json) => TestEntry( + id: json['id'] as String, + name: json['name'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + ); + + TestEntry copyWith({bool? enabled}) => TestEntry( + id: id, + name: name, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn, + ); +} + +List _createDefaults() => const [ + TestEntry(id: 'default-1', name: 'Default One', isBuiltIn: true), + TestEntry(id: 'default-2', name: 'Default Two', isBuiltIn: true), +]; + +void main() { + late ServiceRegistry registry; + late int changeCount; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + changeCount = 0; + registry = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () => changeCount++, + ); + }); + + group('load', () { + test('fresh install creates and persists defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(registry.entries[1].id, 'default-2'); + + // Verify persisted + expect(prefs.containsKey('test_entries'), isTrue); + }); + + test('existing valid JSON loads entries', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[0].id, 'custom'); + }); + + test('corrupted JSON falls back to defaults', () async { + SharedPreferences.setMockInitialValues({ + 'test_entries': 'not valid json!!!', + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + }); + + test('adds missing built-in entries to existing list', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + // default-2 is missing + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[2].id, 'default-2'); + }); + }); + + group('addOrUpdate', () { + test('new entry appended to end', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New One'), + ); + + expect(registry.entries, hasLength(3)); + expect(registry.entries.last.id, 'new-one'); + expect(changeCount, 1); + }); + + test('existing entry replaced in-place preserving position', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'default-1', name: 'Updated Name', isBuiltIn: true), + ); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].name, 'Updated Name'); + expect(changeCount, 1); + }); + + test('persists changes', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New'), + ); + + // Re-load from prefs to verify persistence + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries, hasLength(3)); + expect(registry2.entries.last.id, 'new-one'); + }); + }); + + group('delete', () { + test('removes entry and persists', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Add a custom entry first so we have 3 + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.delete('custom'); + + expect(registry.entries, hasLength(2)); + expect(registry.findById('custom'), isNull); + expect(changeCount, 1); + }); + + test('throws StateError on last entry', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Remove one default so only one remains + await registry.delete('default-2'); + + await expectLater( + () => registry.delete('default-1'), + throwsA(isA()), + ); + }); + + test('no-op for nonexistent id', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.delete('nonexistent'); + expect(registry.entries, hasLength(2)); + expect(changeCount, 0); + }); + }); + + group('reorder', () { + test('moves entry from first to last', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'third', name: 'Third'), + ); + changeCount = 0; + + await registry.reorder(0, 3); // Move index 0 to after index 2 + + expect(registry.entries[0].id, 'default-2'); + expect(registry.entries[1].id, 'third'); + expect(registry.entries[2].id, 'default-1'); + expect(changeCount, 1); + }); + + test('same index is no-op', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 0); + + expect(changeCount, 0); + }); + + test('persists new order', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 2); + + // Re-load + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries[0].id, 'default-2'); + expect(registry2.entries[1].id, 'default-1'); + }); + }); + + group('resetToDefaults', () { + test('replaces all entries with defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.resetToDefaults(); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(changeCount, 1); + }); + + test('custom entries removed', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom-1', name: 'C1'), + ); + await registry.addOrUpdate( + const TestEntry(id: 'custom-2', name: 'C2'), + ); + + await registry.resetToDefaults(); + + expect(registry.findById('custom-1'), isNull); + expect(registry.findById('custom-2'), isNull); + }); + }); + + group('enabledEntries', () { + test('filters disabled entries', () async { + // Include the defaults (which load will check for) plus custom entries + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: true, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + const TestEntry(id: 'c', name: 'C', enabled: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + final enabled = registry.enabledEntries; + expect(enabled, hasLength(2)); + expect(enabled[0].id, 'default-1'); + expect(enabled[1].id, 'c'); + }); + + test('returns empty list when all disabled', () async { + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: false, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.enabledEntries, isEmpty); + }); + }); + + group('findById', () { + test('returns entry when found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('default-1')?.name, 'Default One'); + }); + + test('returns null when not found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('nonexistent'), isNull); + }); + }); + + group('entries list immutability', () { + test('entries returns unmodifiable list', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect( + () => registry.entries.add(const TestEntry(id: 'x', name: 'X')), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart index 8e56a242..a5535064 100644 --- a/test/state/settings_state_test.dart +++ b/test/state/settings_state_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/state/settings_state.dart'; import 'package:deflockapp/keys.dart'; @@ -70,4 +72,157 @@ void main() { expect(state.uploadMode, UploadMode.simulate); }); }); + + group('Endpoint registry', () { + test('fresh install loads defaults for both registries', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.routingEndpoints[0].id, 'routing-deflock'); + expect(state.overpassEndpoints, hasLength(2)); + expect(state.overpassEndpoints[0].id, 'overpass-deflock'); + }); + + test('registries return unmodifiable lists', () async { + final state = SettingsState(); + await state.init(); + + expect( + () => state.routingEndpoints.add( + const ServiceEndpoint(id: 'x', name: 'X', url: 'https://x.com'), + ), + throwsA(isA()), + ); + }); + + test('registry mutations fire notifications', () async { + final state = SettingsState(); + await state.init(); + + int notifyCount = 0; + state.addListener(() => notifyCount++); + + await state.routingRegistry.addOrUpdate( + const ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.com'), + ); + expect(notifyCount, 1); + }); + + test('enabledRoutingEndpoints filters disabled entries', () async { + final state = SettingsState(); + await state.init(); + + // Disable the first routing endpoint + final first = state.routingEndpoints[0]; + await state.routingRegistry.addOrUpdate( + first.copyWith(enabled: false), + ); + + expect(state.enabledRoutingEndpoints, hasLength(1)); + expect(state.enabledRoutingEndpoints[0].id, 'routing-alprwatch'); + }); + }); + + group('Endpoint migration', () { + test('old routing_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state = SettingsState(); + await state.init(); + + // Should have 3 endpoints: custom first, then 2 defaults + expect(state.routingEndpoints, hasLength(3)); + expect(state.routingEndpoints[0].url, 'https://custom.example.com/route'); + expect(state.routingEndpoints[0].name, 'Custom'); + expect(state.routingEndpoints[1].id, 'routing-deflock'); + + // Old key should be removed + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + + test('old overpass_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'overpass_endpoint': 'https://custom-overpass.example.com/api/interpreter', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.overpassEndpoints, hasLength(3)); + expect(state.overpassEndpoints[0].url, 'https://custom-overpass.example.com/api/interpreter'); + }); + + test('old key matching a default URL does not create duplicate', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://api.dontgetflocked.com/api/v1/deflock/directions', + }); + + final state = SettingsState(); + await state.init(); + + // Should just have the 2 defaults, no duplicate custom entry + expect(state.routingEndpoints, hasLength(2)); + }); + + test('no old key with no new key creates defaults', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.overpassEndpoints, hasLength(2)); + }); + + test('new format takes precedence over old format', () async { + final existing = [ + const ServiceEndpoint(id: 'custom', name: 'Existing', url: 'https://existing.com'), + ]; + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://old.example.com/route', + 'routing_endpoints': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final state = SettingsState(); + await state.init(); + + // New format should take precedence; old key is NOT migrated when new exists + // The registry should load existing + add missing defaults + expect(state.routingEndpoints[0].url, 'https://existing.com'); + }); + + test('migration is idempotent', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state1 = SettingsState(); + await state1.init(); + final count1 = state1.routingEndpoints.length; + + // Init again (simulating app restart) + final state2 = SettingsState(); + await state2.init(); + + expect(state2.routingEndpoints.length, count1); + }); + + test('empty old endpoint string does not create custom entry', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': '', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + + // Old key should be cleaned up + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + }); } +