551 lines
15 KiB
Dart
551 lines
15 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
|
import 'dart:convert';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
|
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
|
import 'package:motorcycle_dash/services/routes/valhalla_route_provider.dart';
|
|
import 'package:motorcycle_dash/services/routes/aws_location_route_provider.dart';
|
|
|
|
void main() {
|
|
group('ValhallaRouteProvider Tests', () {
|
|
test(
|
|
'throws authenticationRequired when token provider returns null',
|
|
() async {
|
|
final provider = ValhallaRouteProvider(
|
|
backendUrl: 'http://localhost:3000',
|
|
tokenProvider: () async => null,
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: 's1',
|
|
displayName: 'Start',
|
|
latitude: 37.77,
|
|
longitude: -122.42,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: 's2',
|
|
displayName: 'End',
|
|
latitude: 37.80,
|
|
longitude: -122.27,
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
expect(
|
|
() => provider.calculateRoute(stops),
|
|
throwsA(
|
|
isA<RouteProviderException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'authenticationRequired',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('validates minimum 2 stops', () async {
|
|
final provider = ValhallaRouteProvider(
|
|
tokenProvider: () async => 'valid-mock-token',
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: 's1',
|
|
displayName: 'Single Stop',
|
|
latitude: 37.77,
|
|
longitude: -122.42,
|
|
order: 0,
|
|
),
|
|
];
|
|
|
|
expect(
|
|
() => provider.calculateRoute(stops),
|
|
throwsA(
|
|
isA<RouteProviderException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'invalidRequest',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('validates maximum 25 stops', () async {
|
|
final provider = ValhallaRouteProvider(
|
|
tokenProvider: () async => 'valid-mock-token',
|
|
);
|
|
|
|
final stops = List.generate(
|
|
26,
|
|
(i) => RouteStop(
|
|
id: 's$i',
|
|
displayName: 'Stop $i',
|
|
latitude: 37.0 + i * 0.01,
|
|
longitude: -122.0,
|
|
order: i,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
() => provider.calculateRoute(stops),
|
|
throwsA(
|
|
isA<RouteProviderException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'invalidRequest',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'calculates 2-stop route and parses normalized result with precision 6 geometry',
|
|
() async {
|
|
final mockClient = MockClient((request) async {
|
|
expect(request.url.path, '/api/routes/calculate');
|
|
expect(request.headers['authorization'], 'Bearer valid-test-token');
|
|
|
|
final payload = jsonDecode(request.body) as Map<String, dynamic>;
|
|
expect((payload['locations'] as List).length, 2);
|
|
|
|
// Return fixture for SF to Oakland
|
|
return http.Response(
|
|
jsonEncode({
|
|
'provider': 'valhalla_stadia',
|
|
'encodedPolyline': 'gbr`gAnk{nhFwrx@om`H',
|
|
'distanceMeters': 18500.5,
|
|
'durationSeconds': 1320,
|
|
'warnings': ['Motorcycle route preview'],
|
|
'attribution': '© Stadia Maps',
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'valid-test-token',
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: 's1',
|
|
displayName: 'SF',
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: 's2',
|
|
displayName: 'Oakland',
|
|
latitude: 37.8044,
|
|
longitude: -122.2712,
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
final result = await provider.calculateRoute(stops);
|
|
|
|
expect(result.isStraightLine, false);
|
|
expect(result.estimatedDistanceMeters, 18500.5);
|
|
expect(result.estimatedDurationSeconds, 1320);
|
|
expect(result.geometry.length, 2);
|
|
expect(result.geometry[0].latitude, closeTo(37.7749, 1e-4));
|
|
expect(result.geometry[1].latitude, closeTo(37.8044, 1e-4));
|
|
expect(result.warnings, contains('Motorcycle route preview'));
|
|
},
|
|
);
|
|
|
|
test('decodes each route leg independently', () async {
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'encodedPolylines': [
|
|
'gbr`gAnk{nhFwrx@om`H',
|
|
'gbr`gAnk{nhFwrx@om`H',
|
|
],
|
|
'distanceMeters': 1000,
|
|
'durationSeconds': 60,
|
|
'warnings': [],
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'valid-test-token',
|
|
);
|
|
|
|
final result = await provider.calculateRoute([
|
|
const RouteStop(
|
|
id: '1',
|
|
displayName: '1',
|
|
latitude: 37,
|
|
longitude: -122,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: '2',
|
|
displayName: '2',
|
|
latitude: 37.1,
|
|
longitude: -122,
|
|
order: 1,
|
|
),
|
|
const RouteStop(
|
|
id: '3',
|
|
displayName: '3',
|
|
latitude: 37.2,
|
|
longitude: -122,
|
|
order: 2,
|
|
),
|
|
]);
|
|
|
|
expect(result.geometry.length, 4);
|
|
});
|
|
|
|
test(
|
|
'searches places and parses coordinates from search results',
|
|
() async {
|
|
final mockClient = MockClient((request) async {
|
|
expect(request.url.path, '/api/places/search');
|
|
expect(request.url.queryParameters['query'], 'Alice Restaurant');
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'results': [
|
|
{
|
|
'providerId': 'place_123',
|
|
'primaryText': "Alice's Restaurant",
|
|
'secondaryText': 'Woodside, CA',
|
|
'formattedAddress':
|
|
"Alice's Restaurant, Skyline Blvd, Woodside, CA",
|
|
'latitude': 37.3868,
|
|
'longitude': -122.2644,
|
|
},
|
|
],
|
|
'attribution': 'Powered by Stadia Maps',
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'valid-test-token',
|
|
);
|
|
|
|
final results = await provider.searchPlaces('Alice Restaurant');
|
|
|
|
expect(results.length, 1);
|
|
final place = results.first;
|
|
expect(place.providerId, 'place_123');
|
|
expect(place.primaryText, "Alice's Restaurant");
|
|
expect(place.hasCoordinates, true);
|
|
expect(place.latitude, 37.3868);
|
|
expect(place.longitude, -122.2644);
|
|
|
|
// Verify immediate resolution without second network call
|
|
final resolved = place.toResolvedPlace();
|
|
expect(resolved, isNotNull);
|
|
expect(resolved!.latitude, 37.3868);
|
|
expect(resolved.displayName, "Alice's Restaurant");
|
|
},
|
|
);
|
|
|
|
test('resolves place details by placeId', () async {
|
|
final mockClient = MockClient((request) async {
|
|
expect(request.url.path, '/api/places/resolve');
|
|
expect(request.url.queryParameters['placeId'], 'gid_999');
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'providerId': 'gid_999',
|
|
'displayName': 'Mount Hamilton Observatory',
|
|
'formattedAddress': 'Mount Hamilton Rd, San Jose, CA',
|
|
'latitude': 37.3414,
|
|
'longitude': -121.6429,
|
|
'attribution': 'Powered by Stadia Maps',
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'valid-test-token',
|
|
);
|
|
|
|
final resolved = await provider.resolvePlace('gid_999');
|
|
|
|
expect(resolved.providerId, 'gid_999');
|
|
expect(resolved.displayName, 'Mount Hamilton Observatory');
|
|
expect(resolved.latitude, 37.3414);
|
|
expect(resolved.longitude, -121.6429);
|
|
});
|
|
|
|
test('maps backend 404 to routeNotFound exception', () async {
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'error': 'routeNotFound',
|
|
'message': 'No route could be found',
|
|
}),
|
|
404,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'valid-test-token',
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: '1',
|
|
displayName: '1',
|
|
latitude: 0,
|
|
longitude: 0,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: '2',
|
|
displayName: '2',
|
|
latitude: 1,
|
|
longitude: 1,
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
expect(
|
|
() => provider.calculateRoute(stops),
|
|
throwsA(
|
|
isA<RouteProviderException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'routeNotFound',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
test('routing request after bootstrap sends Bearer token', () async {
|
|
var tokenProviderCalled = false;
|
|
final mockClient = MockClient((request) async {
|
|
expect(request.headers['authorization'], 'Bearer my-bootstrap-token');
|
|
return http.Response(
|
|
jsonEncode({
|
|
'encodedPolyline': 'w~j_~A_{n~P',
|
|
'distanceMeters': 1234.5,
|
|
'durationSeconds': 600,
|
|
'warnings': [],
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async {
|
|
tokenProviderCalled = true;
|
|
return 'my-bootstrap-token';
|
|
},
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: '1',
|
|
displayName: '1',
|
|
latitude: 10,
|
|
longitude: 10,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: '2',
|
|
displayName: '2',
|
|
latitude: 20,
|
|
longitude: 20,
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
await provider.calculateRoute(stops);
|
|
expect(tokenProviderCalled, true);
|
|
});
|
|
|
|
test('search request after bootstrap sends Bearer token', () async {
|
|
var tokenProviderCalled = false;
|
|
final mockClient = MockClient((request) async {
|
|
expect(
|
|
request.headers['authorization'],
|
|
'Bearer search-bootstrap-token',
|
|
);
|
|
return http.Response(
|
|
jsonEncode({'results': [], 'attribution': 'Test'}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async {
|
|
tokenProviderCalled = true;
|
|
return 'search-bootstrap-token';
|
|
},
|
|
);
|
|
|
|
await provider.searchPlaces('cafe');
|
|
expect(tokenProviderCalled, true);
|
|
});
|
|
|
|
test('resolve request after bootstrap sends Bearer token', () async {
|
|
var tokenProviderCalled = false;
|
|
final mockClient = MockClient((request) async {
|
|
expect(
|
|
request.headers['authorization'],
|
|
'Bearer resolve-bootstrap-token',
|
|
);
|
|
return http.Response(
|
|
jsonEncode({
|
|
'providerId': 'place_123',
|
|
'displayName': 'A Place',
|
|
'latitude': 10.0,
|
|
'longitude': 10.0,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async {
|
|
tokenProviderCalled = true;
|
|
return 'resolve-bootstrap-token';
|
|
},
|
|
);
|
|
|
|
await provider.resolvePlace('place_123');
|
|
expect(tokenProviderCalled, true);
|
|
});
|
|
});
|
|
|
|
group('Route Provider Configuration and Activation Tests', () {
|
|
test('configured backendUrl is honored', () {
|
|
final customProvider = ValhallaRouteProvider(
|
|
backendUrl: 'http://custom-host:8080',
|
|
tokenProvider: () async => 'token',
|
|
);
|
|
expect(customProvider.backendUrl, 'http://custom-host:8080');
|
|
});
|
|
|
|
test(
|
|
'activeRouteCalculationProvider resolves to AwsLocationRouteProvider by default',
|
|
() {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
final activeProvider = container.read(activeRouteCalculationProvider);
|
|
expect(activeProvider, isA<AwsLocationRouteProvider>());
|
|
expect(activeProvider.name, contains('Amazon'));
|
|
},
|
|
);
|
|
|
|
|
|
test(
|
|
'provider errors remain explicit and do not silently fall back to straight line',
|
|
() async {
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'error': 'providerUnavailable',
|
|
'message': 'Routing server unavailable',
|
|
}),
|
|
503,
|
|
);
|
|
});
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'token',
|
|
);
|
|
|
|
final stops = [
|
|
const RouteStop(
|
|
id: '1',
|
|
displayName: '1',
|
|
latitude: 10,
|
|
longitude: 10,
|
|
order: 0,
|
|
),
|
|
const RouteStop(
|
|
id: '2',
|
|
displayName: '2',
|
|
latitude: 20,
|
|
longitude: 20,
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
expect(
|
|
() => provider.calculateRoute(stops),
|
|
throwsA(
|
|
isA<RouteProviderException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
'providerUnavailable',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('falls back to fallbackProvider when primary relay fails', () async {
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response('Internal Server Error', 500);
|
|
});
|
|
|
|
final mockFallback = MockClient((request) async {
|
|
if (request.url.path.contains('/v2/autocomplete')) {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'ResultItems': [
|
|
{
|
|
'PlaceId': 'fallback_place_1',
|
|
'Title': 'Fallback Place',
|
|
'Address': {'Label': '123 Fallback Rd'},
|
|
}
|
|
]
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
return http.Response('Not Found', 404);
|
|
});
|
|
|
|
final awsFallback = AwsLocationRouteProvider(
|
|
apiKey: 'test-key',
|
|
client: mockFallback,
|
|
);
|
|
|
|
final provider = ValhallaRouteProvider(
|
|
client: mockClient,
|
|
tokenProvider: () async => 'token',
|
|
fallbackProvider: awsFallback,
|
|
);
|
|
|
|
final results = await provider.searchPlaces('Fallback');
|
|
expect(results.length, equals(1));
|
|
expect(results.first.primaryText, equals('Fallback Place'));
|
|
});
|
|
});
|
|
}
|