import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; 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 {} void main() { late MockHttpClient mockClient; late OverpassService service; final bounds = LatLngBounds( const LatLng(38.8, -77.1), const LatLng(39.0, -76.9), ); final profiles = [ NodeProfile( id: 'test-alpr', name: 'Test ALPR', tags: const { 'man_made': 'surveillance', 'surveillance:type': 'ALPR', }, ), ]; setUpAll(() { registerFallbackValue(Uri.parse('https://example.com')); }); setUp(() { mockClient = MockHttpClient(); // Initialize OverpassService with a mock HTTP client for testing service = OverpassService(client: mockClient); }); /// Helper: stub a successful Overpass response with the given elements. void stubOverpassResponse(List> elements) { when(() => mockClient.post(any(), body: any(named: 'body'))) .thenAnswer((_) async => http.Response( jsonEncode({'elements': elements}), 200, )); } /// Helper: stub an error response. void stubErrorResponse(int statusCode, String body) { when(() => mockClient.post(any(), body: any(named: 'body'))) .thenAnswer((_) async => http.Response(body, statusCode)); } group('query building', () { test('uses the way/relation membership pass with restriction conversion', () async { stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: profiles); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; expect(query, contains('.allNodes out body;')); expect(query, contains('way(bn.allNodes);')); expect(query, contains('relation(bn.allNodes);')); expect(query, contains('.resNodes')); expect(query, contains('convert restriction ::id = id();')); expect(query, isNot(contains('out meta;'))); expect(query, isNot(contains('out ids;'))); }); test('empty tag values are excluded from filters', () async { final profileWithEmpty = [ NodeProfile( id: 'test', name: 'Test', tags: const { 'man_made': 'surveillance', 'camera:mount': '', // empty — should be excluded }, ), ]; stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: profileWithEmpty); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; expect(query, contains('["man_made"="surveillance"]')); expect(query, isNot(contains('camera:mount'))); }); }); group('profile deduplication (subsumption)', () { test('a specific profile subsumed by a generic one is dropped', () async { final generic = NodeProfile( id: 'generic-alpr', name: 'Generic ALPR', tags: const { 'man_made': 'surveillance', 'surveillance:type': 'ALPR', }, ); final specific = NodeProfile( id: 'flock', name: 'Flock', tags: const { 'man_made': 'surveillance', 'surveillance': 'public', 'surveillance:type': 'ALPR', 'surveillance:zone': 'traffic', 'camera:type': 'fixed', 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435', }, ); stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: [generic, specific]); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; // Only the generic clause should remain. expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]')); expect(query, isNot(contains('manufacturer'))); expect(query, isNot(contains('Flock Safety'))); // Only one distinct tag-filter clause, appearing twice (once for // .allNodes, once for the .resNodes re-filter). final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length; expect(nodeClauseCount, equals(2)); }); test('unrelated profiles are all kept', () async { final alprGeneric = NodeProfile( id: 'generic-alpr', name: 'Generic ALPR', tags: const { 'man_made': 'surveillance', 'surveillance:type': 'ALPR', }, ); final gunshotGeneric = NodeProfile( id: 'generic-gunshot', name: 'Generic Gunshot Detector', tags: const { 'man_made': 'surveillance', 'surveillance:type': 'gunshot_detector', }, ); stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: [alprGeneric, gunshotGeneric]); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; expect(query, contains('["surveillance:type"="ALPR"]')); expect(query, contains('["surveillance:type"="gunshot_detector"]')); }); test('full default profile set reduces to the two generic clauses', () async { final profiles = NodeProfile.getDefaults(); stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: profiles); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; // Each of the 2 generic clauses appears twice (once in .allNodes, // once in the .resNodes re-filter) = 4 total tag-filter clauses. final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length; expect(nodeClauseCount, equals(4)); expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]')); expect(query, contains('["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]')); // Brand-specific tags should not appear anywhere in the reduced query. expect(query, isNot(contains('manufacturer'))); expect(query, isNot(contains('ShotSpotter'))); }); test('profiles with only empty tags do not subsume or break the query', () async { final emptyTagsProfile = NodeProfile( id: 'empty', name: 'Empty', tags: const {'camera:mount': ''}, ); final normalProfile = NodeProfile( id: 'normal', name: 'Normal', tags: const {'man_made': 'surveillance'}, ); stubOverpassResponse([]); await service.fetchNodes(bounds: bounds, profiles: [emptyTagsProfile, normalProfile]); final captured = verify( () => mockClient.post(any(), body: captureAny(named: 'body')), ).captured; final query = (captured.last as Map)['data']!; expect(query, contains('["man_made"="surveillance"]')); }); }); group('response parsing — constraint detection', () { test('a node referenced as a "restriction" element is constrained', () async { // The Overpass query converts each way/relation-referenced node ID // into a synthetic {"type":"restriction","id":} element // (via `convert restriction ::id = id();`) — this is how the server // tells us a node is part of some way/relation, regardless of whether // that way/relation came from a "way" or a "relation" in Overpass. stubOverpassResponse([ { 'type': 'node', 'id': 1, 'lat': 38.9, 'lon': -77.0, 'tags': {'man_made': 'surveillance'}, }, { 'type': 'node', 'id': 2, 'lat': 38.91, 'lon': -77.01, 'tags': {'man_made': 'surveillance'}, }, { 'type': 'restriction', 'id': 1, }, ]); final nodes = await service.fetchNodes(bounds: bounds, profiles: profiles); expect(nodes, hasLength(2)); final node1 = nodes.firstWhere((n) => n.id == 1); final node2 = nodes.firstWhere((n) => n.id == 2); expect(node1.isConstrained, isTrue); expect(node2.isConstrained, isFalse); }); test('nodes not marked with a "restriction" element are unconstrained', () async { stubOverpassResponse([ { 'type': 'node', 'id': 4, 'lat': 38.9, 'lon': -77.0, 'tags': {'man_made': 'surveillance'}, }, ]); final nodes = await service.fetchNodes(bounds: bounds, profiles: profiles); expect(nodes, hasLength(1)); expect(nodes.first.isConstrained, isFalse); }); test('mixed response with multiple constrained and unconstrained nodes', () async { stubOverpassResponse([ { 'type': 'node', 'id': 10, 'lat': 38.9, 'lon': -77.0, 'tags': {'man_made': 'surveillance'}, }, { 'type': 'node', 'id': 11, 'lat': 38.91, 'lon': -77.01, 'tags': {'man_made': 'surveillance'}, }, { 'type': 'node', 'id': 12, 'lat': 38.92, 'lon': -77.02, 'tags': {'man_made': 'surveillance'}, }, { 'type': 'restriction', 'id': 10, }, { 'type': 'restriction', 'id': 11, }, ]); final nodes = await service.fetchNodes(bounds: bounds, profiles: profiles); expect(nodes, hasLength(3)); expect(nodes.firstWhere((n) => n.id == 10).isConstrained, isTrue); expect(nodes.firstWhere((n) => n.id == 11).isConstrained, isTrue); expect(nodes.firstWhere((n) => n.id == 12).isConstrained, isFalse); }); }); group('error handling', () { test('HTTP 200 returns parsed nodes', () async { stubOverpassResponse([ { 'type': 'node', 'id': 1, 'lat': 38.9, 'lon': -77.0, 'tags': {'man_made': 'surveillance'}, }, ]); final nodes = await service.fetchNodes(bounds: bounds, profiles: profiles); expect(nodes, hasLength(1)); expect(nodes.first.id, equals(1)); }); test( 'HTTP 400 with "too many nodes" and "50000" throws NodeLimitError', () async { stubErrorResponse( 400, 'Error: too many nodes (limit is 50000) in query'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('response with "timeout" throws NetworkError (not split)', () async { stubErrorResponse(400, 'runtime error: timeout in query execution'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('response with "runtime limit exceeded" throws NetworkError (not split)', () async { stubErrorResponse(400, 'runtime limit exceeded'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('HTTP 429 throws RateLimitError', () async { stubErrorResponse(429, 'Too Many Requests'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('response with "rate limited" throws RateLimitError', () async { stubErrorResponse(503, 'You are rate limited'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('other HTTP errors with retries exhausted throws NetworkError', () async { stubErrorResponse(500, 'Internal Server Error'); await expectLater( () => service.fetchNodes( bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), throwsA(isA()), ); }); test('empty profiles returns empty list without making request', () async { final nodes = await service.fetchNodes(bounds: bounds, profiles: []); expect(nodes, isEmpty); 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')); }); }); }