fix: activate and validate real motorcycle routing

This commit is contained in:
Developer 2026-09-06 12:24:45 -04:00
parent 92ce23cdb7
commit 3ca69f9be4
24 changed files with 474 additions and 36 deletions

View File

@ -4,4 +4,5 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@ -1,4 +1,4 @@
import 'dart:async';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart';
@ -72,6 +72,8 @@ class _AddressAutocompleteFieldState
bool _showDropdown = false;
bool _hasSearched = false;
String _lastText = '';
@override
void initState() {
super.initState();
@ -92,8 +94,17 @@ class _AddressAutocompleteFieldState
}
_resolvedPlace = widget.initialPlace;
_lastText = _controller.text;
_focusNode.addListener(_handleFocusChange);
_controller.addListener(_handleControllerChange);
}
void _handleControllerChange() {
if (_controller.text != _lastText) {
_lastText = _controller.text;
_onTextChanged(_controller.text);
}
}
@override
@ -105,6 +116,7 @@ class _AddressAutocompleteFieldState
if (_controller.text != newText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.text != newText) {
_lastText = newText;
_controller.text = newText;
}
});
@ -115,6 +127,7 @@ class _AddressAutocompleteFieldState
@override
void dispose() {
_debounceTimer?.cancel();
_controller.removeListener(_handleControllerChange);
_focusNode.removeListener(_handleFocusChange);
if (_internalFocusNode) {
_focusNode.dispose();
@ -232,6 +245,7 @@ class _AddressAutocompleteFieldState
void _applyResolvedPlace(ResolvedPlace place) {
_resolvedPlace = place;
_lastText = place.displayName;
_controller.text = place.displayName;
setState(() {
_isResolving = false;
@ -244,6 +258,7 @@ class _AddressAutocompleteFieldState
}
void _clear() {
_lastText = '';
_controller.clear();
_resolvedPlace = null;
_suggestions = const [];

View File

@ -20,6 +20,7 @@ abstract class AuthService {
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
Future<void> signInWithGoogle();
Future<void> signInAnonymously();
Future<void> ensureBackendIdentity();
Future<void> sendPasswordResetEmail(String email);
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
@ -351,6 +352,38 @@ class FirebaseAuthService implements AuthService {
}
}
Future<void>? _identityBootstrapFuture;
@override
Future<void> ensureBackendIdentity() {
final auth = _firebaseAuth;
if (auth != null && auth.currentUser != null) {
return Future.value();
}
_identityBootstrapFuture ??= _performIdentityBootstrap().whenComplete(() {
_identityBootstrapFuture = null;
});
return _identityBootstrapFuture!;
}
Future<void> _performIdentityBootstrap() async {
final auth = _firebaseAuth;
if (auth == null || !isConfigured) {
throw StateError('Firebase is not configured. Backend identity cannot be established.');
}
if (auth.currentUser != null) return;
try {
final credential = await auth.signInAnonymously();
if (credential.user != null) {
final user = _mapFirebaseUser(credential.user!);
_updateState(AuthState.anonymous(user, isFirebaseConfigured: true));
}
} catch (e) {
throw Exception('Backend identity bootstrap failed: $e');
}
}
@override
Future<void> sendPasswordResetEmail(String email) async {
final auth = _firebaseAuth;

View File

@ -5,10 +5,6 @@ import 'package:latlong2/latlong.dart' as ll;
import 'map_provider_interface.dart';
class OpenStreetMapProvider implements AppMapProvider {
final MapController _mapController = MapController();
ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
@override
AppMapType get type => AppMapType.openStreetMap;
@ -27,9 +23,60 @@ class OpenStreetMapProvider implements AppMapProvider {
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
}) {
final centerLl = ll.LatLng(center.latitude, center.longitude);
return _OsmMapView(
center: center,
zoom: zoom,
markers: markers,
polylines: polylines,
isFollowMode: isFollowMode,
config: config,
onPositionChanged: onPositionChanged,
onUserInteraction: onUserInteraction,
);
}
}
if (isFollowMode) {
class _OsmMapView extends StatefulWidget {
final AppLatLng center;
final double zoom;
final List<AppMapMarker> markers;
final List<AppPolyline> polylines;
final bool isFollowMode;
final MapConfig config;
final ValueChanged<AppLatLng>? onPositionChanged;
final VoidCallback? onUserInteraction;
const _OsmMapView({
required this.center,
required this.zoom,
required this.markers,
required this.polylines,
required this.isFollowMode,
required this.config,
required this.onPositionChanged,
required this.onUserInteraction,
});
@override
State<_OsmMapView> createState() => _OsmMapViewState();
}
class _OsmMapViewState extends State<_OsmMapView> {
late final MapController _mapController;
ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
@override
void initState() {
super.initState();
_mapController = MapController();
}
@override
void didUpdateWidget(covariant _OsmMapView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFollowMode) {
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
final now = DateTime.now();
final posDelta = _lastFollowPos == null
? 1.0
@ -43,13 +90,25 @@ class OpenStreetMapProvider implements AppMapProvider {
_lastFollowPos = centerLl;
_lastFollowTime = now;
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_mapController.move(centerLl, zoom);
} catch (_) {}
if (mounted) {
try {
_mapController.move(centerLl, widget.zoom);
} catch (_) {}
}
});
}
}
}
@override
void dispose() {
_mapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude);
final media = MediaQuery.of(context);
final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
@ -58,30 +117,30 @@ class OpenStreetMapProvider implements AppMapProvider {
mapController: _mapController,
options: MapOptions(
initialCenter: centerLl,
initialZoom: zoom,
minZoom: config.minZoom,
maxZoom: config.maxZoom,
initialZoom: widget.zoom,
minZoom: widget.config.minZoom,
maxZoom: widget.config.maxZoom,
backgroundColor: const Color(0xFF0F0F0F),
onPositionChanged: (position, hasGesture) {
if (hasGesture) {
onUserInteraction?.call();
widget.onUserInteraction?.call();
}
if (onPositionChanged != null) {
if (widget.onPositionChanged != null) {
final c = position.center;
onPositionChanged(AppLatLng(c.latitude, c.longitude));
widget.onPositionChanged!(AppLatLng(c.latitude, c.longitude));
}
},
),
children: [
TileLayer(
urlTemplate: config.tileUrlTemplate,
subdomains: config.subdomains,
urlTemplate: widget.config.tileUrlTemplate,
subdomains: widget.config.subdomains,
userAgentPackageName: 'app.motorsignal.motorcycle',
maxZoom: 20,
maxNativeZoom: 19,
),
PolylineLayer(
polylines: polylines.map((p) {
polylines: widget.polylines.map((p) {
return Polyline(
points: p.points.map((pt) => ll.LatLng(pt.latitude, pt.longitude)).toList(),
strokeWidth: p.width,
@ -90,7 +149,7 @@ class OpenStreetMapProvider implements AppMapProvider {
}).toList(),
),
MarkerLayer(
markers: markers.map((m) {
markers: widget.markers.map((m) {
return Marker(
point: ll.LatLng(m.position.latitude, m.position.longitude),
width: 50,
@ -113,7 +172,7 @@ class OpenStreetMapProvider implements AppMapProvider {
RichAttributionWidget(
attributions: [
TextSourceAttribution(
config.attributionText,
widget.config.attributionText,
textStyle: const TextStyle(color: Colors.white70, fontSize: 10),
),
],

View File

@ -5,15 +5,21 @@ import 'navigation_handoff.dart';
import 'route_models.dart';
import 'route_provider.dart';
import 'route_service.dart';
import 'valhalla_route_provider.dart';
/// Provider for RouteService.
final routeServiceProvider = Provider<RouteService>((ref) {
return RouteService();
});
/// Active RouteProvider implementation (defaults to honest straight-line preview).
/// Active RouteProvider implementation.
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) {
return const StraightLineRouteProvider();
final authService = ref.watch(authServiceProvider);
return ValhallaRouteProvider(
tokenProvider: () async {
await authService.ensureBackendIdentity();
return await authService.getIdToken(true);
},
);
});
/// Provider for NavigationHandoffService.

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
@ -11,20 +12,32 @@ import 'valhalla_polyline_decoder.dart';
class ValhallaRouteProvider implements RouteProvider {
final String backendUrl;
final http.Client _client;
final Future<String?> Function()? _tokenProvider;
final Future<String?> Function()? tokenProvider;
ValhallaRouteProvider({
this.backendUrl = 'http://10.0.2.2:3000',
String? backendUrl,
http.Client? client,
this._tokenProvider,
}) : _client = client ?? http.Client();
this.tokenProvider,
}) : _client = client ?? http.Client(),
backendUrl = backendUrl ?? _getEffectiveBackendUrl();
static String _getEffectiveBackendUrl() {
const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL');
if (definedUrl.isNotEmpty) {
return definedUrl;
}
if (kReleaseMode) {
throw StateError('MOTORSIGNAL_API_BASE_URL is not configured for production build.');
}
return 'http://10.0.2.2:3000';
}
@override
String get name => 'Stadia Maps (Valhalla)';
Future<Map<String, String>> _getAuthHeaders() async {
String? token;
final customTokenProvider = _tokenProvider;
final customTokenProvider = tokenProvider;
if (customTokenProvider != null) {
token = await customTokenProvider();
} else {

View File

@ -1,9 +1,13 @@
const admin = require('firebase-admin');
const { getAuth } = require('firebase-admin/auth');
// Initialize Firebase Admin if not already initialized
try {
if (admin.apps && admin.apps.length === 0) {
admin.initializeApp();
const apps = admin.getApps ? admin.getApps() : (admin.apps || []);
if (apps.length === 0) {
admin.initializeApp({
projectId: process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0'
});
}
} catch (e) {
console.warn('Firebase admin initialization failed:', e.message);
@ -24,7 +28,8 @@ async function requireAuth(req, res, next) {
const idToken = authHeader.split('Bearer ')[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const auth = typeof admin.auth === 'function' ? admin.auth() : getAuth();
const decodedToken = await auth.verifyIdToken(idToken);
req.user = decodedToken;
next();
} catch (error) {

View File

@ -1,4 +1,4 @@
const express = require('express');
const express = require('express');
const axios = require('axios');
const rateLimit = require('express-rate-limit');
const { requireAuth } = require('../middleware/firebase_auth');
@ -114,7 +114,7 @@ router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, r
res.json({
results: normalized,
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
});
} catch (error) {
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
@ -190,7 +190,7 @@ router.get('/resolve', requireAppCheck, requireAuth, resolveLimiter, async (req,
formattedAddress: props.label || props.name || '',
latitude: coords[1],
longitude: coords[0],
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
});
} catch (error) {
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {

View File

@ -9,6 +9,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
class _Auth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
String? token = 'test-token';
AuthUser? user = const AuthUser(

View File

@ -18,6 +18,8 @@ import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true);
bool reauthenticated = false;

View File

@ -8,6 +8,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
class _Auth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
bool reauthenticated = false;
bool deleted = false;

View File

@ -0,0 +1,156 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:motorcycle_dash/services/auth/auth_service.dart';
class MockFirebaseAuth implements fb.FirebaseAuth {
fb.User? _currentUser;
final StreamController<fb.User?> _authStateChangesController = StreamController<fb.User?>.broadcast();
int signInAnonymouslyCallCount = 0;
bool shouldThrowOnSignIn = false;
MockFirebaseAuth(this._currentUser);
@override
fb.User? get currentUser => _currentUser;
@override
Stream<fb.User?> authStateChanges() async* {
yield _currentUser;
yield* _authStateChangesController.stream;
}
@override
Future<fb.UserCredential> signInAnonymously() async {
signInAnonymouslyCallCount++;
if (shouldThrowOnSignIn) {
throw fb.FirebaseAuthException(code: 'network-error', message: 'Network disconnected');
}
await Future.delayed(const Duration(milliseconds: 50));
final user = MockUser('new_anon', true, null);
_currentUser = user;
_authStateChangesController.add(user);
return MockUserCredential(user);
}
@override
dynamic noSuchMethod(Invocation invocation) {
if (invocation.isGetter) return null;
return super.noSuchMethod(invocation);
}
}
class MockUser implements fb.User {
@override
final String uid;
@override
final bool isAnonymous;
@override
final String? email;
@override
final bool emailVerified = false;
@override
final fb.UserMetadata metadata;
@override
final List<fb.UserInfo> providerData = const [];
MockUser(this.uid, this.isAnonymous, this.email) : metadata = MockUserMetadata();
@override
dynamic noSuchMethod(Invocation invocation) {
if (invocation.isGetter) return null;
return super.noSuchMethod(invocation);
}
}
class MockUserMetadata implements fb.UserMetadata {
@override
DateTime? get creationTime => DateTime.now();
@override
DateTime? get lastSignInTime => DateTime.now();
@override
dynamic noSuchMethod(Invocation invocation) {
if (invocation.isGetter) return null;
return super.noSuchMethod(invocation);
}
}
class MockUserCredential implements fb.UserCredential {
@override
final fb.User? user;
MockUserCredential(this.user);
@override
dynamic noSuchMethod(Invocation invocation) {
if (invocation.isGetter) return null;
return super.noSuchMethod(invocation);
}
}
void main() {
group('AuthService ensureBackendIdentity Tests', () {
test('current registered user -> reused, no anonymous sign-in', () async {
final fakeUser = MockUser('reg_123', false, 'test@example.com');
final fakeAuth = MockFirebaseAuth(fakeUser);
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
await Future.delayed(Duration.zero); // allow init to run
await service.ensureBackendIdentity();
expect(fakeAuth.signInAnonymouslyCallCount, 0);
expect(service.currentUser?.isAnonymous, false);
expect(service.currentUser?.uid, 'reg_123');
});
test('current anonymous user -> reused', () async {
final fakeUser = MockUser('anon_123', true, null);
final fakeAuth = MockFirebaseAuth(fakeUser);
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
await Future.delayed(Duration.zero);
await service.ensureBackendIdentity();
expect(fakeAuth.signInAnonymouslyCallCount, 0);
expect(service.currentUser?.isAnonymous, true);
});
test('null user -> one anonymous sign-in', () async {
final fakeAuth = MockFirebaseAuth(null);
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
await Future.delayed(Duration.zero);
await service.ensureBackendIdentity();
expect(fakeAuth.signInAnonymouslyCallCount, 1);
expect(service.currentState.user?.uid, 'new_anon');
});
test('two simultaneous requests -> only one anonymous sign-in', () async {
final fakeAuth = MockFirebaseAuth(null);
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
await Future.delayed(Duration.zero);
final future1 = service.ensureBackendIdentity();
final future2 = service.ensureBackendIdentity();
await Future.wait([future1, future2]);
expect(fakeAuth.signInAnonymouslyCallCount, 1);
expect(service.currentState.user?.uid, 'new_anon');
});
test('anonymous sign-in failure -> explicit provider/auth error', () async {
final fakeAuth = MockFirebaseAuth(null);
fakeAuth.shouldThrowOnSignIn = true;
final service = FirebaseAuthService(firebaseAuth: fakeAuth);
await Future.delayed(Duration.zero);
expect(
() => service.ensureBackendIdentity(),
throwsA(isA<Exception>().having((e) => e.toString(), 'msg', contains('Network disconnected')))
);
});
});
}

View File

@ -16,6 +16,7 @@ class FakeAuthService implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
final _stateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
bool _isConfigured = true;

View File

@ -22,6 +22,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
AuthUser? user;

View File

@ -22,6 +22,8 @@ import 'package:drift/native.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
AuthUser? user;

View File

@ -84,6 +84,8 @@ const Map<String, Size> imeViewports = {
};
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
AuthUser? user;
final _states = StreamController<AuthState>.broadcast();

View File

@ -24,6 +24,7 @@ class _TestAuthService implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
AuthUser? _user;
String? token = 'token';
bool configured = true;

View File

@ -21,6 +21,7 @@ class TestMockAuthService implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
AuthUser? _user;
AuthState _state = const AuthState.unauthenticated();

View File

@ -28,6 +28,7 @@ class MockAuthService implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
AuthUser? _user;
AuthState _state = const AuthState.unauthenticated();

View File

@ -17,6 +17,7 @@ class _Auth implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
AuthUser? user;
@override bool get isConfigured => true;
@override AuthUser? get currentUser => user;

View File

@ -20,6 +20,7 @@ class MockSecurityAuthService implements AuthService {
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
AuthUser? _user;
String? _token;

View File

@ -14,6 +14,8 @@ import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast();
AuthUser? user;

View File

@ -1,3 +1,5 @@
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;
@ -108,7 +110,7 @@ void main() {
'distanceMeters': 18500.5,
'durationSeconds': 1320,
'warnings': ['Motorcycle route preview'],
'attribution': '© Stadia Maps',
'attribution': '© Stadia Maps',
}),
200,
headers: {'content-type': 'application/json'},
@ -257,5 +259,125 @@ void main() {
)),
);
});
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',
)),
);
});
});
}