MotorSignal-App/test/valhalla_route_provider_tes...

384 lines
12 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';
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('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 ValhallaRouteProvider by default', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final activeProvider = container.read(activeRouteCalculationProvider);
expect(activeProvider, isA<ValhallaRouteProvider>());
expect(activeProvider.name, 'Stadia Maps (Valhalla)');
});
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',
)),
);
});
});
}