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. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest> </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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart'; import '../../../core/theme/theme_extensions.dart';
@ -72,6 +72,8 @@ class _AddressAutocompleteFieldState
bool _showDropdown = false; bool _showDropdown = false;
bool _hasSearched = false; bool _hasSearched = false;
String _lastText = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -92,8 +94,17 @@ class _AddressAutocompleteFieldState
} }
_resolvedPlace = widget.initialPlace; _resolvedPlace = widget.initialPlace;
_lastText = _controller.text;
_focusNode.addListener(_handleFocusChange); _focusNode.addListener(_handleFocusChange);
_controller.addListener(_handleControllerChange);
}
void _handleControllerChange() {
if (_controller.text != _lastText) {
_lastText = _controller.text;
_onTextChanged(_controller.text);
}
} }
@override @override
@ -105,6 +116,7 @@ class _AddressAutocompleteFieldState
if (_controller.text != newText) { if (_controller.text != newText) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.text != newText) { if (mounted && _controller.text != newText) {
_lastText = newText;
_controller.text = newText; _controller.text = newText;
} }
}); });
@ -115,6 +127,7 @@ class _AddressAutocompleteFieldState
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_controller.removeListener(_handleControllerChange);
_focusNode.removeListener(_handleFocusChange); _focusNode.removeListener(_handleFocusChange);
if (_internalFocusNode) { if (_internalFocusNode) {
_focusNode.dispose(); _focusNode.dispose();
@ -232,6 +245,7 @@ class _AddressAutocompleteFieldState
void _applyResolvedPlace(ResolvedPlace place) { void _applyResolvedPlace(ResolvedPlace place) {
_resolvedPlace = place; _resolvedPlace = place;
_lastText = place.displayName;
_controller.text = place.displayName; _controller.text = place.displayName;
setState(() { setState(() {
_isResolving = false; _isResolving = false;
@ -244,6 +258,7 @@ class _AddressAutocompleteFieldState
} }
void _clear() { void _clear() {
_lastText = '';
_controller.clear(); _controller.clear();
_resolvedPlace = null; _resolvedPlace = null;
_suggestions = const []; _suggestions = const [];

View File

@ -20,6 +20,7 @@ abstract class AuthService {
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}); Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
Future<void> signInWithGoogle(); Future<void> signInWithGoogle();
Future<void> signInAnonymously(); Future<void> signInAnonymously();
Future<void> ensureBackendIdentity();
Future<void> sendPasswordResetEmail(String email); Future<void> sendPasswordResetEmail(String email);
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.'); 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.'); 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 @override
Future<void> sendPasswordResetEmail(String email) async { Future<void> sendPasswordResetEmail(String email) async {
final auth = _firebaseAuth; final auth = _firebaseAuth;

View File

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

View File

@ -5,15 +5,21 @@ import 'navigation_handoff.dart';
import 'route_models.dart'; import 'route_models.dart';
import 'route_provider.dart'; import 'route_provider.dart';
import 'route_service.dart'; import 'route_service.dart';
import 'valhalla_route_provider.dart';
/// Provider for RouteService. /// Provider for RouteService.
final routeServiceProvider = Provider<RouteService>((ref) { final routeServiceProvider = Provider<RouteService>((ref) {
return RouteService(); return RouteService();
}); });
/// Active RouteProvider implementation (defaults to honest straight-line preview). /// Active RouteProvider implementation.
final activeRouteCalculationProvider = Provider<RouteProvider>((ref) { 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. /// Provider for NavigationHandoffService.

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -11,20 +12,32 @@ import 'valhalla_polyline_decoder.dart';
class ValhallaRouteProvider implements RouteProvider { class ValhallaRouteProvider implements RouteProvider {
final String backendUrl; final String backendUrl;
final http.Client _client; final http.Client _client;
final Future<String?> Function()? _tokenProvider; final Future<String?> Function()? tokenProvider;
ValhallaRouteProvider({ ValhallaRouteProvider({
this.backendUrl = 'http://10.0.2.2:3000', String? backendUrl,
http.Client? client, http.Client? client,
this._tokenProvider, this.tokenProvider,
}) : _client = client ?? http.Client(); }) : _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 @override
String get name => 'Stadia Maps (Valhalla)'; String get name => 'Stadia Maps (Valhalla)';
Future<Map<String, String>> _getAuthHeaders() async { Future<Map<String, String>> _getAuthHeaders() async {
String? token; String? token;
final customTokenProvider = _tokenProvider; final customTokenProvider = tokenProvider;
if (customTokenProvider != null) { if (customTokenProvider != null) {
token = await customTokenProvider(); token = await customTokenProvider();
} else { } else {

View File

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

View File

@ -1,4 +1,4 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { requireAuth } = require('../middleware/firebase_auth'); const { requireAuth } = require('../middleware/firebase_auth');
@ -114,7 +114,7 @@ router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, r
res.json({ res.json({
results: normalized, results: normalized,
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors' attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
}); });
} catch (error) { } catch (error) {
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { 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 || '', formattedAddress: props.label || props.name || '',
latitude: coords[1], latitude: coords[1],
longitude: coords[0], longitude: coords[0],
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors' attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors'
}); });
} catch (error) { } catch (error) {
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { 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'; import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
class _Auth implements AuthService { class _Auth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast(); final _states = StreamController<AuthState>.broadcast();
String? token = 'test-token'; String? token = 'test-token';
AuthUser? user = const AuthUser( 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'; import 'package:shared_preferences/shared_preferences.dart';
class _FakeAuth implements AuthService { class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast(); final _states = StreamController<AuthState>.broadcast();
AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true); AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true);
bool reauthenticated = false; 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'; import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
class _Auth implements AuthService { class _Auth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com'); AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
bool reauthenticated = false; bool reauthenticated = false;
bool deleted = 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> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {} @override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {} @override Future<void> deleteCurrentUser() async {}
@override Future<void> ensureBackendIdentity() async {}
final _stateController = StreamController<AuthState>.broadcast(); final _stateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true); AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
bool _isConfigured = 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'; import 'package:motorcycle_dash/features/records/records_service.dart';
class _FakeAuth implements AuthService { class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast(); final _states = StreamController<AuthState>.broadcast();
AuthUser? user; AuthUser? user;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,8 @@ import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class _FakeAuth implements AuthService { class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
final _states = StreamController<AuthState>.broadcast(); final _states = StreamController<AuthState>.broadcast();
AuthUser? user; 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 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -108,7 +110,7 @@ void main() {
'distanceMeters': 18500.5, 'distanceMeters': 18500.5,
'durationSeconds': 1320, 'durationSeconds': 1320,
'warnings': ['Motorcycle route preview'], 'warnings': ['Motorcycle route preview'],
'attribution': '© Stadia Maps', 'attribution': '© Stadia Maps',
}), }),
200, 200,
headers: {'content-type': 'application/json'}, 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',
)),
);
});
}); });
} }