fix: activate and validate real motorcycle routing
This commit is contained in:
parent
92ce23cdb7
commit
3ca69f9be4
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue