diff --git a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart index 9650ab9c0..c98f81605 100644 --- a/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart +++ b/lib/src/layer/tile_layer/tile_provider/network_image_provider.dart @@ -86,8 +86,13 @@ class FlutterMapNetworkImageProvider headers: headers, ), ), - ); + ).catchError((dynamic e) { + // ignore: only_throw_errors + if (useFallback || fallbackUrl == null) throw e as Object; + return _loadAsync(key, chunkEvents, decode, useFallback: true); + }); } catch (_) { + // This redundancy necessary, do not remove if (useFallback || fallbackUrl == null) rethrow; return _loadAsync(key, chunkEvents, decode, useFallback: true); } diff --git a/pubspec.yaml b/pubspec.yaml index f9783d6e4..46d347622 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dev_dependencies: flutter_lints: ^2.0.1 flutter_test: sdk: flutter - mocktail: ^0.3.0 + mocktail: ^1.0.0 test: ^1.21.4 flutter: diff --git a/test/layer/tile_layer/tile_provider/network_image_provider_test.dart b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart new file mode 100644 index 000000000..5852866e2 --- /dev/null +++ b/test/layer/tile_layer/tile_provider/network_image_provider_test.dart @@ -0,0 +1,231 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_provider/network_image_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../test_utils/test_tile_image.dart'; + +class MockHttpClient extends Mock implements BaseClient {} + +// Helper function to resolve the ImageInfo from the ImageProvider. +Future getImageInfo(ImageProvider provider) { + final completer = Completer(); + + final ImageStream stream = provider.resolve(ImageConfiguration.empty); + stream.addListener( + ImageStreamListener( + (imageInfo, _) { + print('completer.complete($imageInfo)'); + return completer.complete(imageInfo); + }, + onError: (exception, stackTrace) { + print('completer.completeError($exception)'); + return completer.completeError(exception, stackTrace); + }, + ), + ); + + return completer.future; +} + +/// Returns a random URL to use for testing. Due to Flutter caching images +/// we need to use a different URL each time. +int _urlId = 0; +Uri randomUrl({bool fallback = false}) { + _urlId++; + if (fallback) { + return Uri.parse('https://example.net/fallback/$_urlId.png'); + } else { + return Uri.parse('https://example.com/main/$_urlId.png'); + } +} + +void main() { + const headers = { + 'user-agent': 'flutter_map', + 'x-whatever': '123', + }; + + const defaultTimeout = Timeout(Duration(seconds: 1)); + + setUpAll(() { + // Ensure the Mock library has example values for Uri. + registerFallbackValue(Uri()); + }); + + // We expect a request to be made to the correct URL with the appropriate headers. + testWidgets('test load with correct url/headers', (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + print('1'); + return testWhiteTileBytes; + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, timeout: defaultTimeout); + + // We expect the request to be made, and a HTTP ClientException to be bubbled + // up to the caller. + testWidgets('test load with server failure (no fallback)', (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + print('2'); + throw ClientException( + 'Server error', + ); + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, timeout: defaultTimeout); + + // We expect the regular URL to be called once, then the fallback URL. + testWidgets('test load with server error (with successful fallback)', + (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async { + throw ClientException( + 'Server error', + ); + }); + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); + }, timeout: defaultTimeout); + + testWidgets('test load with server error (with failed fallback)', + (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + final fallbackUrl = randomUrl(fallback: true); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + throw ClientException( + 'Server error', + ); + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); + }, timeout: defaultTimeout); + + testWidgets('test load with invalid response (no fallback)', (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + when(() => mockClient.readBytes(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: null, + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNull); + expect(tester.takeException(), isInstanceOf()); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + }, timeout: defaultTimeout); + + testWidgets('test load with invalid response (with successful fallback)', + (tester) async { + final mockClient = MockHttpClient(); + final url = randomUrl(); + when(() => mockClient.readBytes(url, headers: any(named: 'headers'))) + .thenAnswer((_) async { + // 200 OK with html + return Uint8List.fromList(utf8.encode('Server Error')); + }); + final fallbackUrl = randomUrl(fallback: true); + when(() => + mockClient.readBytes(fallbackUrl, headers: any(named: 'headers'))) + .thenAnswer((_) async { + return testWhiteTileBytes; + }); + + final provider = FlutterMapNetworkImageProvider( + url: url.toString(), + fallbackUrl: fallbackUrl.toString(), + headers: headers, + httpClient: mockClient, + ); + + final img = await tester.runAsync(() => getImageInfo(provider)); + expect(img, isNotNull); + expect(img!.image.width, equals(256)); + expect(img.image.height, equals(256)); + + verify(() => mockClient.readBytes(url, headers: headers)).called(1); + verify(() => mockClient.readBytes(fallbackUrl, headers: headers)).called(1); + }, timeout: defaultTimeout); +} diff --git a/test/test_utils/test_tile_image.dart b/test/test_utils/test_tile_image.dart index 086bee067..085505da8 100644 --- a/test/test_utils/test_tile_image.dart +++ b/test/test_utils/test_tile_image.dart @@ -5,4 +5,5 @@ import 'package:flutter/painting.dart'; // Base 64 encoded 256x256 white tile. const _whiteTile = 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRF////p8QbyAAAAB9JREFUeJztwQENAAAAwqD3T20ON6AAAAAAAAAAAL4NIQAAAfFnIe4AAAAASUVORK5CYII='; -final testWhiteTileImage = MemoryImage(base64Decode(_whiteTile)); +final testWhiteTileBytes = base64Decode(_whiteTile); +final testWhiteTileImage = MemoryImage(testWhiteTileBytes);