feat: add real motorcycle routing provider foundation
This commit is contained in:
parent
2319222f22
commit
9958a8a600
|
|
@ -1,20 +1,75 @@
|
|||
import '../map/map_provider_interface.dart';
|
||||
import 'route_models.dart';
|
||||
|
||||
/// Exceptions domain mapped from standard provider API.
|
||||
class RouteProviderException implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
RouteProviderException(this.code, this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'RouteProviderException: $message ($code)';
|
||||
}
|
||||
|
||||
/// Result of a place/location search query.
|
||||
class PlaceSearchResult {
|
||||
final String name;
|
||||
final String address;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String? placeId;
|
||||
final String providerId;
|
||||
final String primaryText;
|
||||
final String secondaryText;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? formattedAddress;
|
||||
final String? attribution;
|
||||
|
||||
const PlaceSearchResult({
|
||||
required this.name,
|
||||
this.address = '',
|
||||
required this.providerId,
|
||||
required this.primaryText,
|
||||
required this.secondaryText,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.formattedAddress,
|
||||
this.attribution,
|
||||
});
|
||||
|
||||
bool get hasCoordinates => latitude != null && longitude != null;
|
||||
|
||||
AppLatLng? get latLng =>
|
||||
hasCoordinates ? AppLatLng(latitude!, longitude!) : null;
|
||||
|
||||
/// Converts directly into a [ResolvedPlace] when coordinates are already present.
|
||||
ResolvedPlace? toResolvedPlace() {
|
||||
if (!hasCoordinates) return null;
|
||||
return ResolvedPlace(
|
||||
providerId: providerId,
|
||||
displayName: primaryText,
|
||||
formattedAddress: formattedAddress ??
|
||||
(secondaryText.isNotEmpty
|
||||
? '$primaryText, $secondaryText'
|
||||
: primaryText),
|
||||
latitude: latitude!,
|
||||
longitude: longitude!,
|
||||
attribution: attribution,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved place with authoritative coordinates.
|
||||
class ResolvedPlace {
|
||||
final String providerId;
|
||||
final String displayName;
|
||||
final String? formattedAddress;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String? attribution;
|
||||
|
||||
const ResolvedPlace({
|
||||
required this.providerId,
|
||||
required this.displayName,
|
||||
this.formattedAddress,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.placeId,
|
||||
this.attribution,
|
||||
});
|
||||
|
||||
AppLatLng get latLng => AppLatLng(latitude, longitude);
|
||||
|
|
@ -28,6 +83,7 @@ class RouteCalculationResult {
|
|||
final List<AppLatLng> geometry;
|
||||
final bool isStraightLine;
|
||||
final String providerName;
|
||||
final List<String> warnings;
|
||||
|
||||
const RouteCalculationResult({
|
||||
required this.straightLineDistanceMeters,
|
||||
|
|
@ -36,6 +92,7 @@ class RouteCalculationResult {
|
|||
required this.geometry,
|
||||
required this.isStraightLine,
|
||||
required this.providerName,
|
||||
this.warnings = const [],
|
||||
});
|
||||
|
||||
/// Alias for geometry points.
|
||||
|
|
@ -67,13 +124,21 @@ abstract class RouteProvider {
|
|||
String get name;
|
||||
|
||||
/// Calculates route geometry and metrics between ordered stops.
|
||||
Future<RouteCalculationResult> calculateRoute(List<RouteStop> stops);
|
||||
Future<RouteCalculationResult> calculateRoute(
|
||||
List<RouteStop> stops, {
|
||||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
});
|
||||
|
||||
/// Searches for places by textual query.
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
});
|
||||
|
||||
/// Resolves a place ID into full coordinates.
|
||||
Future<ResolvedPlace> resolvePlace(String placeId);
|
||||
}
|
||||
|
||||
/// Default geodesic straight-line preview implementation.
|
||||
|
|
@ -87,11 +152,17 @@ class StraightLineRouteProvider implements RouteProvider {
|
|||
String get name => 'Straight-line Preview';
|
||||
|
||||
@override
|
||||
Future<RouteCalculationResult> calculateRoute(List<RouteStop> stops) async {
|
||||
Future<RouteCalculationResult> calculateRoute(
|
||||
List<RouteStop> stops, {
|
||||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
}) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
final distanceMeters = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
final distanceMeters =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
final points = ordered.map((s) => s.latLng).toList();
|
||||
|
||||
return RouteCalculationResult(
|
||||
|
|
@ -112,35 +183,48 @@ class StraightLineRouteProvider implements RouteProvider {
|
|||
final clean = query.trim();
|
||||
if (clean.isEmpty) return const [];
|
||||
|
||||
// Support direct coordinate input (e.g. "34.5378, -83.9849")
|
||||
// Check if query is direct coordinates (lat, lng)
|
||||
final parts = clean.split(RegExp(r'[,\s]+'));
|
||||
if (parts.length == 2) {
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lng = double.tryParse(parts[1]);
|
||||
if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
if (lat != null &&
|
||||
lng != null &&
|
||||
lat >= -90 &&
|
||||
lat <= 90 &&
|
||||
lng >= -180 &&
|
||||
lng <= 180) {
|
||||
return [
|
||||
PlaceSearchResult(
|
||||
name: 'Coordinate Pin',
|
||||
address: '${lat.toStringAsFixed(5)}, ${lng.toStringAsFixed(5)}',
|
||||
providerId: 'coord_${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}',
|
||||
primaryText: 'Coordinate Pin',
|
||||
secondaryText: '${lat.toStringAsFixed(4)}, ${lng.toStringAsFixed(4)}',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
formattedAddress: '${lat.toStringAsFixed(5)}, ${lng.toStringAsFixed(5)}',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback: return query name centered at reference location if provided
|
||||
if (nearLocation != null) {
|
||||
return [
|
||||
PlaceSearchResult(
|
||||
name: clean,
|
||||
address: 'Near Current Map Location',
|
||||
latitude: nearLocation.latitude,
|
||||
longitude: nearLocation.longitude,
|
||||
providerId: 'manual',
|
||||
primaryText: clean,
|
||||
secondaryText: 'Near Current Map Location',
|
||||
latitude: nearLocation?.latitude,
|
||||
longitude: nearLocation?.longitude,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return const [];
|
||||
@override
|
||||
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
||||
return ResolvedPlace(
|
||||
providerId: placeId,
|
||||
displayName: 'Unknown Place',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import '../map/map_provider_interface.dart';
|
||||
|
||||
/// Decodes Valhalla's precision-6 encoded polyline into standard AppLatLng coordinates.
|
||||
class ValhallaPolylineDecoder {
|
||||
/// Decodes precision-6 polyline string into a list of [AppLatLng].
|
||||
///
|
||||
/// Throws [FormatException] if the encoded string is truncated, contains
|
||||
/// invalid character codes (< 63), or resolves to coordinates outside valid
|
||||
/// geographic bounds (lat: [-90, 90], lng: [-180, 180]).
|
||||
static List<AppLatLng> decode(String encoded) {
|
||||
if (encoded.isEmpty) return const [];
|
||||
|
||||
final List<AppLatLng> polyline = [];
|
||||
int index = 0;
|
||||
int lat = 0;
|
||||
int lng = 0;
|
||||
final int len = encoded.length;
|
||||
|
||||
while (index < len) {
|
||||
// Decode latitude delta
|
||||
int b;
|
||||
int shift = 0;
|
||||
int result = 0;
|
||||
do {
|
||||
if (index >= len) {
|
||||
throw const FormatException('Truncated polyline data while reading latitude');
|
||||
}
|
||||
final code = encoded.codeUnitAt(index++);
|
||||
if (code < 63) {
|
||||
throw FormatException('Invalid polyline character at index ${index - 1}: code $code');
|
||||
}
|
||||
b = code - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final int dlat = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
lat += dlat;
|
||||
|
||||
// Decode longitude delta
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
if (index >= len) {
|
||||
throw const FormatException('Truncated polyline data while reading longitude');
|
||||
}
|
||||
final code = encoded.codeUnitAt(index++);
|
||||
if (code < 63) {
|
||||
throw FormatException('Invalid polyline character at index ${index - 1}: code $code');
|
||||
}
|
||||
b = code - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
|
||||
final int dlng = (result & 1) != 0 ? ~(result >> 1) : (result >> 1);
|
||||
lng += dlng;
|
||||
|
||||
final latDeg = lat / 1e6;
|
||||
final lngDeg = lng / 1e6;
|
||||
|
||||
if (latDeg < -90.0 || latDeg > 90.0 || lngDeg < -180.0 || lngDeg > 180.0) {
|
||||
throw FormatException('Decoded coordinate out of geographic bounds: ($latDeg, $lngDeg)');
|
||||
}
|
||||
|
||||
polyline.add(AppLatLng(latDeg, lngDeg));
|
||||
}
|
||||
|
||||
return polyline;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../map/map_provider_interface.dart';
|
||||
import 'route_models.dart';
|
||||
import 'route_provider.dart';
|
||||
import 'valhalla_polyline_decoder.dart';
|
||||
|
||||
/// Provider integrating with the MotorSignal Node.js routing and places relay.
|
||||
class ValhallaRouteProvider implements RouteProvider {
|
||||
final String backendUrl;
|
||||
final http.Client _client;
|
||||
final Future<String?> Function()? _tokenProvider;
|
||||
|
||||
ValhallaRouteProvider({
|
||||
this.backendUrl = 'http://10.0.2.2:3000',
|
||||
http.Client? client,
|
||||
this._tokenProvider,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
@override
|
||||
String get name => 'Stadia Maps (Valhalla)';
|
||||
|
||||
Future<Map<String, String>> _getAuthHeaders() async {
|
||||
String? token;
|
||||
final customTokenProvider = _tokenProvider;
|
||||
if (customTokenProvider != null) {
|
||||
token = await customTokenProvider();
|
||||
} else {
|
||||
final user = FirebaseAuth.instance.currentUser;
|
||||
if (user == null) {
|
||||
throw RouteProviderException(
|
||||
'authenticationRequired',
|
||||
'User must be authenticated (or signed in as guest) to use cloud routing.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
token = await user.getIdToken();
|
||||
} catch (e) {
|
||||
throw RouteProviderException(
|
||||
'authenticationRequired',
|
||||
'Failed to acquire authentication token: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
throw RouteProviderException(
|
||||
'authenticationRequired',
|
||||
'User must be authenticated to use cloud routing.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
void _handleHttpError(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
|
||||
String code = 'providerError';
|
||||
String msg = 'An unexpected provider error occurred.';
|
||||
|
||||
try {
|
||||
final body = jsonDecode(response.body);
|
||||
if (body is Map<String, dynamic>) {
|
||||
code = body['error']?.toString() ?? code;
|
||||
msg = body['message']?.toString() ?? msg;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
throw RouteProviderException(code, msg);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
}) async {
|
||||
final clean = query.trim();
|
||||
if (clean.length < 2) return const [];
|
||||
|
||||
final headers = await _getAuthHeaders();
|
||||
|
||||
try {
|
||||
final queryParams = <String, String>{
|
||||
'query': clean,
|
||||
};
|
||||
if (nearLocation != null) {
|
||||
queryParams['lat'] = nearLocation.latitude.toString();
|
||||
queryParams['lng'] = nearLocation.longitude.toString();
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$backendUrl/api/places/search')
|
||||
.replace(queryParameters: queryParams);
|
||||
|
||||
final response = await _client
|
||||
.get(uri, headers: headers)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
_handleHttpError(response);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map<String, dynamic>) return const [];
|
||||
|
||||
final results = data['results'] as List<dynamic>? ?? [];
|
||||
final attribution = data['attribution'] as String?;
|
||||
|
||||
return results.whereType<Map<String, dynamic>>().map((r) {
|
||||
final lat = (r['latitude'] as num?)?.toDouble();
|
||||
final lng = (r['longitude'] as num?)?.toDouble();
|
||||
return PlaceSearchResult(
|
||||
providerId: r['providerId']?.toString() ?? '',
|
||||
primaryText: r['primaryText']?.toString() ?? '',
|
||||
secondaryText: r['secondaryText']?.toString() ?? '',
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
formattedAddress: r['formattedAddress']?.toString(),
|
||||
attribution: attribution,
|
||||
);
|
||||
}).toList();
|
||||
} on TimeoutException {
|
||||
throw RouteProviderException(
|
||||
'providerTimeout',
|
||||
'Place search timed out.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is RouteProviderException) rethrow;
|
||||
throw RouteProviderException(
|
||||
'placeSearchUnavailable',
|
||||
'Failed to reach places service: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
||||
final cleanId = placeId.trim();
|
||||
if (cleanId.isEmpty) {
|
||||
throw RouteProviderException(
|
||||
'invalidRequest',
|
||||
'Place ID cannot be empty.',
|
||||
);
|
||||
}
|
||||
|
||||
final headers = await _getAuthHeaders();
|
||||
|
||||
try {
|
||||
final uri = Uri.parse('$backendUrl/api/places/resolve')
|
||||
.replace(queryParameters: {'placeId': cleanId});
|
||||
|
||||
final response = await _client
|
||||
.get(uri, headers: headers)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
_handleHttpError(response);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw RouteProviderException(
|
||||
'placeNotFound',
|
||||
'Invalid response from place resolution.',
|
||||
);
|
||||
}
|
||||
|
||||
return ResolvedPlace(
|
||||
providerId: data['providerId']?.toString() ?? cleanId,
|
||||
displayName: data['displayName']?.toString() ?? 'Unknown Place',
|
||||
formattedAddress: data['formattedAddress']?.toString(),
|
||||
latitude: (data['latitude'] as num).toDouble(),
|
||||
longitude: (data['longitude'] as num).toDouble(),
|
||||
attribution: data['attribution']?.toString(),
|
||||
);
|
||||
} on TimeoutException {
|
||||
throw RouteProviderException(
|
||||
'providerTimeout',
|
||||
'Place resolution timed out.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is RouteProviderException) rethrow;
|
||||
throw RouteProviderException(
|
||||
'placeSearchUnavailable',
|
||||
'Failed to resolve place: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RouteCalculationResult> calculateRoute(
|
||||
List<RouteStop> stops, {
|
||||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
}) async {
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
if (ordered.length < 2) {
|
||||
throw RouteProviderException(
|
||||
'invalidRequest',
|
||||
'At least 2 stops are required to calculate a route.',
|
||||
);
|
||||
}
|
||||
if (ordered.length > 25) {
|
||||
throw RouteProviderException(
|
||||
'invalidRequest',
|
||||
'A maximum of 25 stops is allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
final headers = await _getAuthHeaders();
|
||||
|
||||
final locations = ordered
|
||||
.map((s) => {
|
||||
'lat': s.latitude,
|
||||
'lon': s.longitude,
|
||||
'type': 'break',
|
||||
})
|
||||
.toList();
|
||||
|
||||
try {
|
||||
final uri = Uri.parse('$backendUrl/api/routes/calculate');
|
||||
final response = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'locations': locations,
|
||||
'options': {
|
||||
'avoid_highways': avoidHighways,
|
||||
'avoid_tolls': avoidTolls,
|
||||
'avoid_ferries': avoidFerries,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
_handleHttpError(response);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw RouteProviderException(
|
||||
'routeUnavailable',
|
||||
'Malformed response from route calculation.',
|
||||
);
|
||||
}
|
||||
|
||||
final encodedPolyline = data['encodedPolyline'] as String? ?? '';
|
||||
final distanceMeters =
|
||||
(data['distanceMeters'] as num?)?.toDouble() ?? 0.0;
|
||||
final durationSeconds =
|
||||
(data['durationSeconds'] as num?)?.toInt();
|
||||
final warningsRaw = data['warnings'] as List<dynamic>? ?? [];
|
||||
|
||||
final geometry = ValhallaPolylineDecoder.decode(encodedPolyline);
|
||||
final straightLineDist =
|
||||
MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
|
||||
return RouteCalculationResult(
|
||||
straightLineDistanceMeters: straightLineDist,
|
||||
estimatedDistanceMeters: distanceMeters,
|
||||
estimatedDurationSeconds: durationSeconds,
|
||||
geometry: geometry,
|
||||
isStraightLine: false,
|
||||
providerName: name,
|
||||
warnings: warningsRaw.map((e) => e.toString()).toList(),
|
||||
);
|
||||
} on TimeoutException {
|
||||
throw RouteProviderException(
|
||||
'providerTimeout',
|
||||
'Route calculation timed out.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is RouteProviderException) rethrow;
|
||||
throw RouteProviderException(
|
||||
'routeUnavailable',
|
||||
'Failed to calculate route: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
const { admin } = require('./firebase_auth');
|
||||
|
||||
/**
|
||||
* Express middleware to verify Firebase App Check tokens.
|
||||
*/
|
||||
async function requireAppCheck(req, res, next) {
|
||||
const isEnforced = process.env.ENFORCE_APP_CHECK === 'true';
|
||||
const appCheckToken = req.header ? req.header('X-Firebase-AppCheck') : req.headers?.['x-firebase-appcheck'];
|
||||
|
||||
if (!appCheckToken) {
|
||||
if (isEnforced) {
|
||||
return res.status(403).json({
|
||||
error: 'appCheckFailed',
|
||||
message: 'Missing X-Firebase-AppCheck header'
|
||||
});
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const appCheckClaims = await admin.appCheck().verifyToken(appCheckToken);
|
||||
req.appCheck = appCheckClaims;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (isEnforced) {
|
||||
return res.status(403).json({
|
||||
error: 'appCheckFailed',
|
||||
message: 'Invalid App Check token'
|
||||
});
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { requireAppCheck };
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const admin = require('firebase-admin');
|
||||
|
||||
// Initialize Firebase Admin if not already initialized
|
||||
try {
|
||||
if (admin.apps && admin.apps.length === 0) {
|
||||
admin.initializeApp();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Firebase admin initialization failed:', e.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware to verify Firebase ID tokens.
|
||||
*/
|
||||
async function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'authenticationRequired',
|
||||
message: 'Missing or malformed Authorization header'
|
||||
});
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
|
||||
try {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
req.user = decodedToken;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying Firebase ID token:', error.message);
|
||||
let errorCode = 'invalidToken';
|
||||
if (error.code === 'auth/id-token-expired') errorCode = 'expiredToken';
|
||||
|
||||
return res.status(401).json({
|
||||
error: errorCode,
|
||||
message: 'Invalid or expired Firebase ID token'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, admin };
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,21 +1,26 @@
|
|||
{
|
||||
"name": "motorsignal-waze-relay",
|
||||
"version": "1.0.0",
|
||||
"description": "High-performance headless relay server for live Waze police, speed trap, and hazard alerts",
|
||||
"description": "High-performance relay server for live Waze alerts, motorcycle routing, and geocoding",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
"dev": "node --watch server.js",
|
||||
"test": "node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.7.0",
|
||||
"firebase-admin": "^14.3.0",
|
||||
"puppeteer-core": "^25.9.0"
|
||||
},
|
||||
"keywords": [
|
||||
"waze",
|
||||
"police-alerts",
|
||||
"speed-traps",
|
||||
"routing",
|
||||
"places",
|
||||
"valhalla",
|
||||
"motorsignal"
|
||||
],
|
||||
"author": "MotorSignal",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { requireAuth } = require('../middleware/firebase_auth');
|
||||
const { requireAppCheck } = require('../middleware/app_check');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configurable rate limit parameters
|
||||
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '3600000', 10);
|
||||
const PLACE_SEARCH_RATE_LIMIT_MAX = parseInt(
|
||||
process.env.PLACE_SEARCH_RATE_LIMIT_MAX || process.env.PLACES_RATE_LIMIT || '200',
|
||||
10
|
||||
);
|
||||
const PLACE_RESOLVE_RATE_LIMIT_MAX = parseInt(
|
||||
process.env.PLACE_RESOLVE_RATE_LIMIT_MAX || '200',
|
||||
10
|
||||
);
|
||||
|
||||
const searchLimiter = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
max: PLACE_SEARCH_RATE_LIMIT_MAX,
|
||||
keyGenerator: (req) => req.user?.uid || req.ip,
|
||||
validate: { keyGeneratorIpFallback: false },
|
||||
handler: (req, res) =>
|
||||
res.status(429).json({
|
||||
error: 'providerRateLimited',
|
||||
message: 'Places search rate limit exceeded'
|
||||
})
|
||||
});
|
||||
|
||||
const resolveLimiter = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
max: PLACE_RESOLVE_RATE_LIMIT_MAX,
|
||||
keyGenerator: (req) => req.user?.uid || req.ip,
|
||||
validate: { keyGeneratorIpFallback: false },
|
||||
handler: (req, res) =>
|
||||
res.status(429).json({
|
||||
error: 'providerRateLimited',
|
||||
message: 'Places resolve rate limit exceeded'
|
||||
})
|
||||
});
|
||||
|
||||
router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, res) => {
|
||||
const stadiaApiKey = process.env.STADIA_API_KEY;
|
||||
if (!stadiaApiKey) {
|
||||
return res.status(503).json({
|
||||
error: 'providerUnavailable',
|
||||
message: 'Places provider is not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const query = req.query.query;
|
||||
if (!query || typeof query !== 'string') {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'Valid query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const clean = query.trim();
|
||||
if (clean.length < 2) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'Query must be at least 2 characters'
|
||||
});
|
||||
}
|
||||
|
||||
if (clean.length > 200) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'Query cannot exceed 200 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const params = {
|
||||
text: clean,
|
||||
api_key: stadiaApiKey
|
||||
};
|
||||
|
||||
if (req.query.lat && req.query.lng) {
|
||||
const lat = parseFloat(req.query.lat);
|
||||
const lng = parseFloat(req.query.lng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
||||
params['focus.point.lat'] = lat;
|
||||
params['focus.point.lon'] = lng;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://api.stadiamaps.com/geocoding/v1/autocomplete', {
|
||||
params,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const features = response.data?.features || [];
|
||||
const normalized = [];
|
||||
|
||||
for (const f of features) {
|
||||
const props = f.properties || {};
|
||||
const coords = Array.isArray(f.geometry?.coordinates) ? f.geometry.coordinates : null;
|
||||
const lng = coords && coords.length >= 2 ? coords[0] : null;
|
||||
const lat = coords && coords.length >= 2 ? coords[1] : null;
|
||||
|
||||
normalized.push({
|
||||
providerId: String(props.id || props.gid || ''),
|
||||
primaryText: props.name || props.label || '',
|
||||
secondaryText: [props.region, props.country].filter(Boolean).join(', '),
|
||||
formattedAddress: props.label || props.name || '',
|
||||
latitude: typeof lat === 'number' && Number.isFinite(lat) ? lat : null,
|
||||
longitude: typeof lng === 'number' && Number.isFinite(lng) ? lng : null
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
results: normalized,
|
||||
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
return res.status(504).json({
|
||||
error: 'providerTimeout',
|
||||
message: 'Places search timed out'
|
||||
});
|
||||
}
|
||||
console.error('[Places] Search upstream error:', error.response?.status || error.message);
|
||||
res.status(502).json({
|
||||
error: 'placeSearchUnavailable',
|
||||
message: 'Places search is temporarily unavailable'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/resolve', requireAppCheck, requireAuth, resolveLimiter, async (req, res) => {
|
||||
const stadiaApiKey = process.env.STADIA_API_KEY;
|
||||
if (!stadiaApiKey) {
|
||||
return res.status(503).json({
|
||||
error: 'providerUnavailable',
|
||||
message: 'Places provider is not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const placeId = req.query.placeId;
|
||||
if (!placeId || typeof placeId !== 'string') {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'placeId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const cleanId = placeId.trim();
|
||||
if (cleanId.length === 0 || cleanId.length > 200) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'placeId must be between 1 and 200 characters'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://api.stadiamaps.com/geocoding/v1/place', {
|
||||
params: {
|
||||
ids: cleanId,
|
||||
api_key: stadiaApiKey
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const features = response.data?.features || [];
|
||||
if (features.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'placeNotFound',
|
||||
message: 'Place could not be resolved'
|
||||
});
|
||||
}
|
||||
|
||||
const feature = features[0];
|
||||
const props = feature.properties || {};
|
||||
const coords = feature.geometry?.coordinates || [];
|
||||
|
||||
if (!Array.isArray(coords) || coords.length < 2) {
|
||||
return res.status(404).json({
|
||||
error: 'placeNotFound',
|
||||
message: 'Place has no valid coordinates'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
providerId: String(props.id || props.gid || cleanId),
|
||||
displayName: props.name || props.label || 'Unknown',
|
||||
formattedAddress: props.label || props.name || '',
|
||||
latitude: coords[1],
|
||||
longitude: coords[0],
|
||||
attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
return res.status(504).json({
|
||||
error: 'providerTimeout',
|
||||
message: 'Place resolution timed out'
|
||||
});
|
||||
}
|
||||
console.error('[Places] Resolve upstream error:', error.response?.status || error.message);
|
||||
if (error.response?.status === 404) {
|
||||
return res.status(404).json({
|
||||
error: 'placeNotFound',
|
||||
message: 'Place not found'
|
||||
});
|
||||
}
|
||||
res.status(502).json({
|
||||
error: 'placeSearchUnavailable',
|
||||
message: 'Places service is temporarily unavailable'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { requireAuth } = require('../middleware/firebase_auth');
|
||||
const { requireAppCheck } = require('../middleware/app_check');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configurable rate limit parameters
|
||||
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '3600000', 10);
|
||||
const ROUTE_RATE_LIMIT_MAX = parseInt(
|
||||
process.env.ROUTE_RATE_LIMIT_MAX || process.env.ROUTE_RATE_LIMIT || '50',
|
||||
10
|
||||
);
|
||||
|
||||
// Rate limiter: per user UID (with IP fallback)
|
||||
const routeLimiter = rateLimit({
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
max: ROUTE_RATE_LIMIT_MAX,
|
||||
keyGenerator: (req) => req.user?.uid || req.ip,
|
||||
validate: { keyGeneratorIpFallback: false },
|
||||
handler: (req, res) => {
|
||||
res.status(429).json({
|
||||
error: 'providerRateLimited',
|
||||
message: 'Route calculation rate limit exceeded'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/calculate', requireAppCheck, requireAuth, routeLimiter, async (req, res) => {
|
||||
const stadiaApiKey = process.env.STADIA_API_KEY;
|
||||
if (!stadiaApiKey) {
|
||||
console.error('[Routing] STADIA_API_KEY is not configured');
|
||||
return res.status(503).json({
|
||||
error: 'providerUnavailable',
|
||||
message: 'Routing provider is not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const { locations, options } = req.body || {};
|
||||
|
||||
if (!Array.isArray(locations) || locations.length < 2) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'At least 2 locations are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (locations.length > 25) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: 'Maximum of 25 locations allowed'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate and sanitize each coordinate location
|
||||
const valhallaLocations = [];
|
||||
for (let i = 0; i < locations.length; i++) {
|
||||
const loc = locations[i];
|
||||
if (!loc || typeof loc !== 'object') {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: `Location at index ${i} must be an object`
|
||||
});
|
||||
}
|
||||
|
||||
const lat = loc.lat;
|
||||
const lon = loc.lon ?? loc.lng;
|
||||
|
||||
if (typeof lat !== 'number' || typeof lon !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: `Invalid or non-finite coordinates at index ${i}`
|
||||
});
|
||||
}
|
||||
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
return res.status(400).json({
|
||||
error: 'invalidRequest',
|
||||
message: `Coordinates out of geographic bounds at index ${i}`
|
||||
});
|
||||
}
|
||||
|
||||
// Type is 'break' for origin, destination, and physical stops
|
||||
let type = 'break';
|
||||
if (i > 0 && i < locations.length - 1 && loc.type === 'through') {
|
||||
type = 'through';
|
||||
}
|
||||
|
||||
valhallaLocations.push({ lat, lon, type });
|
||||
}
|
||||
|
||||
// Strictly enforce motorcycle profile server-side; do not accept arbitrary costing
|
||||
const payload = {
|
||||
locations: valhallaLocations,
|
||||
costing: 'motorcycle',
|
||||
directions_options: {
|
||||
units: 'miles'
|
||||
}
|
||||
};
|
||||
|
||||
// Optional avoidance options
|
||||
if (options && typeof options === 'object') {
|
||||
const costingOptions = {};
|
||||
if (options.avoid_highways === true) costingOptions.use_highways = 0;
|
||||
if (options.avoid_tolls === true) costingOptions.use_tolls = 0;
|
||||
if (options.avoid_ferries === true) costingOptions.use_ferry = 0;
|
||||
|
||||
if (Object.keys(costingOptions).length > 0) {
|
||||
payload.costing_options = { motorcycle: costingOptions };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('https://api.stadiamaps.com/route/v1', payload, {
|
||||
params: { api_key: stadiaApiKey },
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (!data || !data.trip || !Array.isArray(data.trip.legs) || data.trip.legs.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'routeNotFound',
|
||||
message: 'No route could be calculated'
|
||||
});
|
||||
}
|
||||
|
||||
let totalDistanceMeters = 0;
|
||||
let totalDurationSeconds = 0;
|
||||
const polylineParts = [];
|
||||
|
||||
for (const leg of data.trip.legs) {
|
||||
if (leg.summary) {
|
||||
totalDistanceMeters += (Number(leg.summary.length) || 0) * 1000;
|
||||
totalDurationSeconds += Math.round(Number(leg.summary.time) || 0);
|
||||
}
|
||||
if (leg.shape) {
|
||||
polylineParts.push(leg.shape);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
provider: 'valhalla_stadia',
|
||||
encodedPolyline: polylineParts.join(''),
|
||||
distanceMeters: Math.round(totalDistanceMeters * 10) / 10,
|
||||
durationSeconds: totalDurationSeconds,
|
||||
warnings: Array.isArray(data.trip.warnings) ? data.trip.warnings : [],
|
||||
attribution: '© Stadia Maps, © OpenStreetMap contributors'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
return res.status(504).json({
|
||||
error: 'providerTimeout',
|
||||
message: 'Routing provider timed out'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response?.status === 400 || error.response?.status === 404) {
|
||||
return res.status(404).json({
|
||||
error: 'routeNotFound',
|
||||
message: 'No road route could be found between the specified locations'
|
||||
});
|
||||
}
|
||||
|
||||
console.error('[Routing] Upstream error occurred:', error.response?.status || error.message);
|
||||
res.status(502).json({
|
||||
error: 'providerUnavailable',
|
||||
message: 'Routing service is temporarily unavailable'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
const express = require('express');
|
||||
const https = require('https');
|
||||
const router = express.Router();
|
||||
|
||||
const cache = new Map();
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
const BROWSER_USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
];
|
||||
|
||||
function getRandomUserAgent() {
|
||||
return BROWSER_USER_AGENTS[Math.floor(Math.random() * BROWSER_USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
function getWazeEnv(lat, lng) {
|
||||
if (lat >= 29.0 && lat <= 33.5 && lng >= 34.0 && lng <= 36.0) {
|
||||
return 'il';
|
||||
}
|
||||
if (lng >= -170.0 && lng <= -50.0) {
|
||||
return 'na';
|
||||
}
|
||||
return 'row';
|
||||
}
|
||||
|
||||
function fetchWazeAlerts(top, bottom, left, right, env = 'na') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = new URLSearchParams({
|
||||
top: top.toString(),
|
||||
bottom: bottom.toString(),
|
||||
left: left.toString(),
|
||||
right: right.toString(),
|
||||
env: env,
|
||||
types: 'alerts'
|
||||
});
|
||||
|
||||
const req = https.request({
|
||||
hostname: 'www.waze.com',
|
||||
port: 443,
|
||||
path: `/rtserver/web/TGeoRSS?${query.toString()}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': getRandomUserAgent(),
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://www.waze.com/live-map/',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
timeout: 8000
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse Waze JSON: ${e.message}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Waze responded with HTTP ${res.statusCode}: ${body.slice(0, 150)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Waze request timed out'));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAlerts(wazeData) {
|
||||
const rawAlerts = wazeData.alerts || [];
|
||||
const normalized = [];
|
||||
|
||||
for (const item of rawAlerts) {
|
||||
const lat = item.location?.y ?? item.latitude ?? item.lat;
|
||||
const lng = item.location?.x ?? item.longitude ?? item.lng;
|
||||
if (lat == null || lng == null) continue;
|
||||
|
||||
const type = (item.type || 'HAZARD').toUpperCase();
|
||||
const subtype = (item.subtype || item.subType || '').toUpperCase();
|
||||
const street = item.street || item.road || '';
|
||||
const city = item.city || '';
|
||||
|
||||
let category = 'other';
|
||||
let description = item.reportDescription || '';
|
||||
|
||||
if (type.includes('POLICE') || subtype.includes('POLICE')) {
|
||||
category = 'police';
|
||||
if (subtype.includes('HIDDEN')) {
|
||||
description = description || 'Hidden police speed trap reported';
|
||||
} else if (subtype.includes('VISIBLE')) {
|
||||
description = description || 'Police speed trap reported';
|
||||
} else {
|
||||
description = description || 'Police presence reported';
|
||||
}
|
||||
} else if (type.includes('SPEED_CAMERA') || subtype.includes('SPEED_CAMERA') || type.includes('CAMERA')) {
|
||||
category = 'speed_camera';
|
||||
description = description || 'Speed enforcement camera';
|
||||
} else if (type.includes('RED_LIGHT_CAMERA') || subtype.includes('RED_LIGHT_CAMERA')) {
|
||||
category = 'speed_camera';
|
||||
description = description || 'Red light camera';
|
||||
} else if (type.includes('ACCIDENT') || subtype.includes('ACCIDENT')) {
|
||||
category = 'accident';
|
||||
description = description || (subtype.includes('MAJOR') ? 'Major collision' : 'Accident reported');
|
||||
} else if (type.includes('HAZARD') || subtype.includes('HAZARD') || subtype.includes('WEATHER') || subtype.includes('DEBRIS')) {
|
||||
category = 'hazard';
|
||||
description = description || (subtype.includes('POT_HOLE') ? 'Pothole on roadway' : 'Road hazard / debris');
|
||||
} else if (type.includes('ROAD_CLOSED') || subtype.includes('ROAD_CLOSED')) {
|
||||
category = 'road_closure';
|
||||
description = description || 'Road closed';
|
||||
} else if (type.includes('CONSTRUCTION') || subtype.includes('CONSTRUCTION')) {
|
||||
category = 'construction';
|
||||
description = description || 'Road work / Construction';
|
||||
} else if (type.includes('JAM') || type.includes('TRAFFIC')) {
|
||||
category = 'traffic';
|
||||
description = description || 'Heavy traffic congestion';
|
||||
}
|
||||
|
||||
if (street) {
|
||||
description += ` on ${street}`;
|
||||
}
|
||||
|
||||
normalized.push({
|
||||
id: item.uuid || item.id || `wz_${lat.toFixed(5)}_${lng.toFixed(5)}`,
|
||||
providerId: 'community_traffic',
|
||||
source: 'Waze Community Relay',
|
||||
type: type,
|
||||
subtype: subtype,
|
||||
category: category,
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lng),
|
||||
heading: item.magvar || item.heading || null,
|
||||
roadName: street,
|
||||
city: city,
|
||||
description: description,
|
||||
confidence: (item.confidence != null ? item.confidence / 10.0 : (item.reportRating != null ? item.reportRating / 5.0 : 0.85)),
|
||||
severity: item.reportRating >= 4 ? 'high' : (item.reportRating >= 2 ? 'medium' : 'low'),
|
||||
reliability: item.reliability || 5,
|
||||
reportRating: item.reportRating || 3,
|
||||
nThumbsUp: item.nThumbsUp || 0,
|
||||
pubMillis: item.pubMillis || Date.now(),
|
||||
reportedAt: new Date(item.pubMillis || Date.now()).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
let top, bottom, left, right, lat, lng;
|
||||
|
||||
if (req.query.top && req.query.bottom && req.query.left && req.query.right) {
|
||||
top = parseFloat(req.query.top);
|
||||
bottom = parseFloat(req.query.bottom);
|
||||
left = parseFloat(req.query.left);
|
||||
right = parseFloat(req.query.right);
|
||||
lat = (top + bottom) / 2;
|
||||
lng = (left + right) / 2;
|
||||
} else if (req.query.lat && req.query.lng) {
|
||||
lat = parseFloat(req.query.lat);
|
||||
lng = parseFloat(req.query.lng);
|
||||
const radiusMeters = parseFloat(req.query.radius || 5000);
|
||||
|
||||
const latDelta = radiusMeters / 111000.0;
|
||||
const lngDelta = radiusMeters / (111000.0 * Math.cos(lat * Math.PI / 180.0));
|
||||
|
||||
top = lat + latDelta;
|
||||
bottom = lat - latDelta;
|
||||
left = lng - lngDelta;
|
||||
right = lng + lngDelta;
|
||||
} else {
|
||||
lat = 37.7749;
|
||||
lng = -122.4194;
|
||||
top = 37.82;
|
||||
bottom = 37.72;
|
||||
left = -122.48;
|
||||
right = -122.36;
|
||||
}
|
||||
|
||||
const env = req.query.env || getWazeEnv(lat, lng);
|
||||
const cacheKey = `${lat.toFixed(2)}_${lng.toFixed(2)}_${env}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.time < CACHE_TTL_MS)) {
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: true,
|
||||
count: cached.alerts.length,
|
||||
alerts: cached.alerts
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const wazeRaw = await fetchWazeAlerts(top, bottom, left, right, env);
|
||||
const normalizedAlerts = normalizeAlerts(wazeRaw);
|
||||
|
||||
cache.set(cacheKey, { time: Date.now(), alerts: normalizedAlerts });
|
||||
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: false,
|
||||
count: normalizedAlerts.length,
|
||||
alerts: normalizedAlerts
|
||||
});
|
||||
} catch (fetchErr) {
|
||||
console.warn(`[WazeRelay] Warning: ${fetchErr.message}`);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: true,
|
||||
stale: true,
|
||||
count: cached.alerts.length,
|
||||
alerts: cached.alerts
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
status: 'partial',
|
||||
message: fetchErr.message,
|
||||
count: 0,
|
||||
alerts: []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WazeRelay] Server Error:', err);
|
||||
res.status(500).json({ error: err.message, alerts: [] });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,301 +1,64 @@
|
|||
const http = require('http');
|
||||
const https = require('https');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const wazeRoutes = require('./routes/waze');
|
||||
const routingRoutes = require('./routes/routing');
|
||||
const placesRoutes = require('./routes/places');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Standard middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// In-memory cache for Waze requests (30s TTL per geo tile)
|
||||
const cache = new Map();
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
// User agents and headers mimicking genuine browser requests
|
||||
const BROWSER_USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
];
|
||||
|
||||
function getRandomUserAgent() {
|
||||
return BROWSER_USER_AGENTS[Math.floor(Math.random() * BROWSER_USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine Waze server environment based on longitude
|
||||
* na: North America (Western Hemisphere approx)
|
||||
* il: Israel
|
||||
* row: Rest of World
|
||||
*/
|
||||
function getWazeEnv(lat, lng) {
|
||||
if (lat >= 29.0 && lat <= 33.5 && lng >= 34.0 && lng <= 36.0) {
|
||||
return 'il';
|
||||
}
|
||||
if (lng >= -170.0 && lng <= -50.0) {
|
||||
return 'na';
|
||||
}
|
||||
return 'row';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw GeoRSS alerts from Waze with browser impersonation
|
||||
*/
|
||||
function fetchWazeAlerts(top, bottom, left, right, env = 'na') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = new URLSearchParams({
|
||||
top: top.toString(),
|
||||
bottom: bottom.toString(),
|
||||
left: left.toString(),
|
||||
right: right.toString(),
|
||||
env: env,
|
||||
types: 'alerts,traffic,jams'
|
||||
});
|
||||
|
||||
const url = `https://www.waze.com/live-map/api/georss?${query.toString()}`;
|
||||
const parsed = new URL(url);
|
||||
|
||||
const req = https.request({
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': getRandomUserAgent(),
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': 'https://www.waze.com/live-map/',
|
||||
'Sec-Ch-Ua': '"Chromium";v="124", "Google Chrome";v="124"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
timeout: 8000
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse Waze JSON: ${e.message}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Waze responded with HTTP ${res.statusCode}: ${body.slice(0, 150)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Waze request timed out'));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and format Waze raw alerts into MotorSignal Community Schema
|
||||
*/
|
||||
function normalizeAlerts(wazeData) {
|
||||
const rawAlerts = wazeData.alerts || [];
|
||||
const normalized = [];
|
||||
|
||||
for (const item of rawAlerts) {
|
||||
const lat = item.location?.y ?? item.latitude ?? item.lat;
|
||||
const lng = item.location?.x ?? item.longitude ?? item.lng;
|
||||
if (lat == null || lng == null) continue;
|
||||
|
||||
const type = (item.type || 'HAZARD').toUpperCase();
|
||||
const subtype = (item.subtype || item.subType || '').toUpperCase();
|
||||
const street = item.street || item.road || '';
|
||||
const city = item.city || '';
|
||||
|
||||
let category = 'other';
|
||||
let description = item.reportDescription || '';
|
||||
|
||||
if (type.includes('POLICE') || subtype.includes('POLICE')) {
|
||||
category = 'police';
|
||||
if (subtype.includes('HIDDEN')) {
|
||||
description = description || 'Hidden police speed trap reported';
|
||||
} else if (subtype.includes('VISIBLE')) {
|
||||
description = description || 'Police speed trap reported';
|
||||
} else {
|
||||
description = description || 'Police presence reported';
|
||||
}
|
||||
} else if (type.includes('SPEED_CAMERA') || subtype.includes('SPEED_CAMERA') || type.includes('CAMERA')) {
|
||||
category = 'speed_camera';
|
||||
description = description || 'Speed enforcement camera';
|
||||
} else if (type.includes('RED_LIGHT_CAMERA') || subtype.includes('RED_LIGHT_CAMERA')) {
|
||||
category = 'speed_camera';
|
||||
description = description || 'Red light camera';
|
||||
} else if (type.includes('ACCIDENT') || subtype.includes('ACCIDENT')) {
|
||||
category = 'accident';
|
||||
description = description || (subtype.includes('MAJOR') ? 'Major collision' : 'Accident reported');
|
||||
} else if (type.includes('HAZARD') || subtype.includes('HAZARD') || subtype.includes('WEATHER') || subtype.includes('DEBRIS')) {
|
||||
category = 'hazard';
|
||||
description = description || (subtype.includes('POT_HOLE') ? 'Pothole on roadway' : 'Road hazard / debris');
|
||||
} else if (type.includes('ROAD_CLOSED') || subtype.includes('ROAD_CLOSED')) {
|
||||
category = 'road_closure';
|
||||
description = description || 'Road closed';
|
||||
} else if (type.includes('CONSTRUCTION') || subtype.includes('CONSTRUCTION')) {
|
||||
category = 'construction';
|
||||
description = description || 'Road work / Construction';
|
||||
} else if (type.includes('JAM') || type.includes('TRAFFIC')) {
|
||||
category = 'traffic';
|
||||
description = description || 'Heavy traffic congestion';
|
||||
}
|
||||
|
||||
if (street) {
|
||||
description += ` on ${street}`;
|
||||
}
|
||||
|
||||
normalized.push({
|
||||
id: item.uuid || item.id || `wz_${lat.toFixed(5)}_${lng.toFixed(5)}`,
|
||||
providerId: 'community_traffic',
|
||||
source: 'Waze Community Relay',
|
||||
type: type,
|
||||
subtype: subtype,
|
||||
category: category,
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lng),
|
||||
heading: item.magvar || item.heading || null,
|
||||
roadName: street,
|
||||
city: city,
|
||||
description: description,
|
||||
confidence: (item.confidence != null ? item.confidence / 10.0 : (item.reportRating != null ? item.reportRating / 5.0 : 0.85)),
|
||||
severity: item.reportRating >= 4 ? 'high' : (item.reportRating >= 2 ? 'medium' : 'low'),
|
||||
reliability: item.reliability || 5,
|
||||
reportRating: item.reportRating || 3,
|
||||
nThumbsUp: item.nThumbsUp || 0,
|
||||
pubMillis: item.pubMillis || Date.now(),
|
||||
reportedAt: new Date(item.pubMillis || Date.now()).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// REST API ROUTES
|
||||
// -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Health check & discovery endpoint (Supports MotorSignal TEST CONNECTION button)
|
||||
*/
|
||||
// Health check & discovery endpoint (Preserves legacy contract for MotorSignal TEST CONNECTION)
|
||||
app.get(['/', '/health', '/test'], (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'MotorSignal Waze Community Relay',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
features: ['police', 'speed_cameras', 'hazards', 'accidents', 'traffic']
|
||||
features: [
|
||||
'police',
|
||||
'speed_cameras',
|
||||
'hazards',
|
||||
'accidents',
|
||||
'traffic',
|
||||
'routing',
|
||||
'places'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Main alerts endpoint
|
||||
*/
|
||||
app.get(['/alerts', '/api/alerts'], async (req, res) => {
|
||||
try {
|
||||
let top, bottom, left, right, lat, lng;
|
||||
// Mount Waze routes on legacy and modern endpoints
|
||||
app.use('/alerts', wazeRoutes);
|
||||
app.use('/api/alerts', wazeRoutes);
|
||||
app.use('/api/waze', wazeRoutes);
|
||||
|
||||
if (req.query.top && req.query.bottom && req.query.left && req.query.right) {
|
||||
top = parseFloat(req.query.top);
|
||||
bottom = parseFloat(req.query.bottom);
|
||||
left = parseFloat(req.query.left);
|
||||
right = parseFloat(req.query.right);
|
||||
lat = (top + bottom) / 2;
|
||||
lng = (left + right) / 2;
|
||||
} else if (req.query.lat && req.query.lng) {
|
||||
lat = parseFloat(req.query.lat);
|
||||
lng = parseFloat(req.query.lng);
|
||||
const radiusMeters = parseFloat(req.query.radius || 5000);
|
||||
// Mount routing and places endpoints
|
||||
app.use('/api/routes', routingRoutes);
|
||||
app.use('/api/places', placesRoutes);
|
||||
|
||||
const latDelta = radiusMeters / 111000.0;
|
||||
const lngDelta = radiusMeters / (111000.0 * Math.cos(lat * Math.PI / 180.0));
|
||||
|
||||
top = lat + latDelta;
|
||||
bottom = lat - latDelta;
|
||||
left = lng - lngDelta;
|
||||
right = lng + lngDelta;
|
||||
} else {
|
||||
lat = 37.7749;
|
||||
lng = -122.4194;
|
||||
top = 37.82;
|
||||
bottom = 37.72;
|
||||
left = -122.48;
|
||||
right = -122.36;
|
||||
}
|
||||
|
||||
const env = req.query.env || getWazeEnv(lat, lng);
|
||||
const cacheKey = `${lat.toFixed(2)}_${lng.toFixed(2)}_${env}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.time < CACHE_TTL_MS)) {
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: true,
|
||||
count: cached.alerts.length,
|
||||
alerts: cached.alerts
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const wazeRaw = await fetchWazeAlerts(top, bottom, left, right, env);
|
||||
const normalizedAlerts = normalizeAlerts(wazeRaw);
|
||||
|
||||
cache.set(cacheKey, { time: Date.now(), alerts: normalizedAlerts });
|
||||
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: false,
|
||||
count: normalizedAlerts.length,
|
||||
alerts: normalizedAlerts
|
||||
});
|
||||
} catch (fetchErr) {
|
||||
console.warn(`[WazeRelay] Warning: ${fetchErr.message}`);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
cached: true,
|
||||
stale: true,
|
||||
count: cached.alerts.length,
|
||||
alerts: cached.alerts
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
provider: 'waze_relay',
|
||||
status: 'partial',
|
||||
message: fetchErr.message,
|
||||
count: 0,
|
||||
alerts: []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WazeRelay] Server Error:', err);
|
||||
res.status(500).json({ error: err.message, alerts: [] });
|
||||
}
|
||||
// Central error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Relay] Unhandled error:', err);
|
||||
res.status(500).json({ error: 'internalError', message: 'An internal server error occurred' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`====================================================`);
|
||||
console.log(` 🏍️ MotorSignal Waze Alert Relay Server running`);
|
||||
console.log(` 🏍️ MotorSignal API & Waze Alert Relay Server running`);
|
||||
console.log(` 📡 Listening on: http://0.0.0.0:${PORT}`);
|
||||
console.log(` 🏥 Healthcheck: http://localhost:${PORT}/health`);
|
||||
console.log(` 🚨 Alerts API: http://localhost:${PORT}/alerts?lat=37.77&lng=-122.42`);
|
||||
console.log(` 🗺️ Routing API: http://localhost:${PORT}/api/routes/calculate`);
|
||||
console.log(` 📍 Places API: http://localhost:${PORT}/api/places/search`);
|
||||
console.log(`====================================================`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { requireAuth, admin } = require('../middleware/firebase_auth');
|
||||
const { requireAppCheck } = require('../middleware/app_check');
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
it('rejects missing Authorization header with 401 authenticationRequired', async () => {
|
||||
const req = { headers: {} };
|
||||
let statusCode = null;
|
||||
let responseBody = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return {
|
||||
json: (b) => {
|
||||
responseBody = b;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let nextCalled = false;
|
||||
await requireAuth(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
assert.strictEqual(nextCalled, false);
|
||||
assert.strictEqual(statusCode, 401);
|
||||
assert.strictEqual(responseBody?.error, 'authenticationRequired');
|
||||
});
|
||||
|
||||
it('rejects malformed Authorization header with 401 authenticationRequired', async () => {
|
||||
const req = { headers: { authorization: 'Basic 12345' } };
|
||||
let statusCode = null;
|
||||
let responseBody = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return {
|
||||
json: (b) => {
|
||||
responseBody = b;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let nextCalled = false;
|
||||
await requireAuth(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
assert.strictEqual(nextCalled, false);
|
||||
assert.strictEqual(statusCode, 401);
|
||||
assert.strictEqual(responseBody?.error, 'authenticationRequired');
|
||||
});
|
||||
|
||||
it('rejects empty Bearer token with 401 invalidToken', async () => {
|
||||
const req = { headers: { authorization: 'Bearer ' } };
|
||||
let statusCode = null;
|
||||
let responseBody = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return {
|
||||
json: (b) => {
|
||||
responseBody = b;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let nextCalled = false;
|
||||
await requireAuth(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
assert.strictEqual(nextCalled, false);
|
||||
assert.strictEqual(statusCode, 401);
|
||||
assert.strictEqual(responseBody?.error, 'invalidToken');
|
||||
});
|
||||
|
||||
it('successfully attaches verified UID from decoded ID token', async () => {
|
||||
const originalAuth = admin.auth;
|
||||
try {
|
||||
// Mock verifyIdToken
|
||||
admin.auth = () => ({
|
||||
verifyIdToken: async (token) => {
|
||||
if (token === 'valid-token') {
|
||||
return { uid: 'rider_alice_123', email: 'alice@example.com' };
|
||||
}
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
});
|
||||
|
||||
const req = { headers: { authorization: 'Bearer valid-token' } };
|
||||
const res = { status: () => ({ json: () => {} }) };
|
||||
let nextCalled = false;
|
||||
|
||||
await requireAuth(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
assert.strictEqual(nextCalled, true);
|
||||
assert.strictEqual(req.user?.uid, 'rider_alice_123');
|
||||
assert.strictEqual(req.user?.email, 'alice@example.com');
|
||||
} finally {
|
||||
admin.auth = originalAuth;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Check Middleware', () => {
|
||||
it('allows request when enforcement is disabled and token is missing', async () => {
|
||||
const req = { header: () => undefined };
|
||||
let nextCalled = false;
|
||||
const res = {
|
||||
status: () => ({ json: () => {} })
|
||||
};
|
||||
await requireAppCheck(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
assert.strictEqual(nextCalled, true);
|
||||
});
|
||||
|
||||
it('fails closed when enforcement is enabled and token is missing', async () => {
|
||||
process.env.ENFORCE_APP_CHECK = 'true';
|
||||
try {
|
||||
const req = { header: () => undefined };
|
||||
let statusCode = null;
|
||||
let responseBody = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return {
|
||||
json: (b) => {
|
||||
responseBody = b;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let nextCalled = false;
|
||||
await requireAppCheck(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
assert.strictEqual(nextCalled, false);
|
||||
assert.strictEqual(statusCode, 403);
|
||||
assert.strictEqual(responseBody?.error, 'appCheckFailed');
|
||||
} finally {
|
||||
delete process.env.ENFORCE_APP_CHECK;
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when enforcement is enabled and token is invalid', async () => {
|
||||
process.env.ENFORCE_APP_CHECK = 'true';
|
||||
try {
|
||||
const req = { header: (h) => (h === 'X-Firebase-AppCheck' ? 'invalid-token' : undefined) };
|
||||
let statusCode = null;
|
||||
let responseBody = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return {
|
||||
json: (b) => {
|
||||
responseBody = b;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
let nextCalled = false;
|
||||
await requireAppCheck(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
assert.strictEqual(nextCalled, false);
|
||||
assert.strictEqual(statusCode, 403);
|
||||
assert.strictEqual(responseBody?.error, 'appCheckFailed');
|
||||
} finally {
|
||||
delete process.env.ENFORCE_APP_CHECK;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows valid App Check token when enforcement is enabled', async () => {
|
||||
process.env.ENFORCE_APP_CHECK = 'true';
|
||||
const originalAppCheck = admin.appCheck;
|
||||
try {
|
||||
admin.appCheck = () => ({
|
||||
verifyToken: async (token) => {
|
||||
if (token === 'valid-app-check-token') {
|
||||
return { appId: 'motorsignal-app-id', token: 'valid-app-check-token' };
|
||||
}
|
||||
throw new Error('Bad token');
|
||||
}
|
||||
});
|
||||
|
||||
const req = { header: (h) => (h === 'X-Firebase-AppCheck' ? 'valid-app-check-token' : undefined) };
|
||||
const res = { status: () => ({ json: () => {} }) };
|
||||
let nextCalled = false;
|
||||
|
||||
await requireAppCheck(req, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
assert.strictEqual(nextCalled, true);
|
||||
assert.strictEqual(req.appCheck?.appId, 'motorsignal-app-id');
|
||||
} finally {
|
||||
admin.appCheck = originalAppCheck;
|
||||
delete process.env.ENFORCE_APP_CHECK;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const axios = require('axios');
|
||||
const routingRouter = require('../routes/routing');
|
||||
const placesRouter = require('../routes/places');
|
||||
|
||||
describe('Routing Input Hardening & Validations', () => {
|
||||
const calcLayer = routingRouter.stack.find((l) => l.route && l.route.path === '/calculate');
|
||||
assert.ok(calcLayer, '/calculate endpoint must exist');
|
||||
const calcHandler = calcLayer.route.stack[calcLayer.route.stack.length - 1].handle;
|
||||
|
||||
it('rejects less than 2 stops with 400 invalidRequest', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = { body: { locations: [{ lat: 37.77, lon: -122.42 }] } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
assert.match(body?.message, /At least 2 locations/);
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects more than 25 stops with 400 invalidRequest', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const stops = Array.from({ length: 26 }, (_, i) => ({
|
||||
lat: 37.0 + i * 0.01,
|
||||
lon: -122.0
|
||||
}));
|
||||
const req = { body: { locations: stops } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
assert.match(body?.message, /Maximum of 25/);
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts exactly 25 stops and passes motorcycle costing to provider', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-secret-key-123';
|
||||
const originalPost = axios.post;
|
||||
try {
|
||||
let interceptedUrl = null;
|
||||
let interceptedPayload = null;
|
||||
let interceptedParams = null;
|
||||
|
||||
axios.post = async (url, payload, config) => {
|
||||
interceptedUrl = url;
|
||||
interceptedPayload = payload;
|
||||
interceptedParams = config?.params;
|
||||
return {
|
||||
data: {
|
||||
trip: {
|
||||
legs: [
|
||||
{
|
||||
summary: { length: 15.5, time: 1200 },
|
||||
shape: 'test_encoded_polyline'
|
||||
}
|
||||
],
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const stops = Array.from({ length: 25 }, (_, i) => ({
|
||||
lat: 37.0 + i * 0.01,
|
||||
lon: -122.0
|
||||
}));
|
||||
|
||||
const req = { body: { locations: stops } };
|
||||
let responseData = null;
|
||||
const res = {
|
||||
json: (b) => { responseData = b; }
|
||||
};
|
||||
|
||||
await calcHandler(req, res);
|
||||
|
||||
assert.strictEqual(interceptedUrl, 'https://api.stadiamaps.com/route/v1');
|
||||
assert.strictEqual(interceptedParams?.api_key, 'test-secret-key-123');
|
||||
assert.strictEqual(interceptedPayload?.costing, 'motorcycle');
|
||||
assert.strictEqual(interceptedPayload?.locations?.length, 25);
|
||||
assert.strictEqual(responseData?.distanceMeters, 15500);
|
||||
assert.strictEqual(responseData?.durationSeconds, 1200);
|
||||
assert.strictEqual(responseData?.encodedPolyline, 'test_encoded_polyline');
|
||||
// Verify API key is NOT leaked in client response
|
||||
assert.strictEqual(JSON.stringify(responseData).includes('test-secret-key-123'), false);
|
||||
} finally {
|
||||
axios.post = originalPost;
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('sanitizes upstream timeout error to 504 providerTimeout without leaking internals', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
const originalPost = axios.post;
|
||||
try {
|
||||
axios.post = async () => {
|
||||
const err = new Error('connect ECONNABORTED');
|
||||
err.code = 'ECONNABORTED';
|
||||
throw err;
|
||||
};
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
locations: [
|
||||
{ lat: 37.7, lon: -122.4 },
|
||||
{ lat: 37.8, lon: -122.4 }
|
||||
]
|
||||
}
|
||||
};
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 504);
|
||||
assert.strictEqual(body?.error, 'providerTimeout');
|
||||
assert.strictEqual(JSON.stringify(body).includes('ECONNABORTED'), false);
|
||||
} finally {
|
||||
axios.post = originalPost;
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid latitude bounds (< -90 or > 90)', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = {
|
||||
body: {
|
||||
locations: [
|
||||
{ lat: 95.0, lon: -122.4 },
|
||||
{ lat: 37.7, lon: -122.4 }
|
||||
]
|
||||
}
|
||||
};
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
assert.match(body?.message, /out of geographic bounds/);
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid longitude bounds (< -180 or > 180)', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = {
|
||||
body: {
|
||||
locations: [
|
||||
{ lat: 37.7, lon: -185.0 },
|
||||
{ lat: 37.7, lon: -122.4 }
|
||||
]
|
||||
}
|
||||
};
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
assert.match(body?.message, /out of geographic bounds/);
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-finite coordinates (NaN/Infinity)', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = {
|
||||
body: {
|
||||
locations: [
|
||||
{ lat: NaN, lon: -122.4 },
|
||||
{ lat: 37.7, lon: -122.4 }
|
||||
]
|
||||
}
|
||||
};
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
assert.match(body?.message, /Invalid or non-finite/);
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('fails with 503 providerUnavailable if STADIA_API_KEY is not configured', async () => {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
const req = { body: { locations: [{ lat: 37.7, lon: -122.4 }, { lat: 37.8, lon: -122.4 }] } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await calcHandler(req, res);
|
||||
assert.strictEqual(statusCode, 503);
|
||||
assert.strictEqual(body?.error, 'providerUnavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Places Input Hardening & Validations', () => {
|
||||
const searchLayer = placesRouter.stack.find((l) => l.route && l.route.path === '/search');
|
||||
assert.ok(searchLayer, '/search endpoint must exist');
|
||||
const searchHandler = searchLayer.route.stack[searchLayer.route.stack.length - 1].handle;
|
||||
|
||||
const resolveLayer = placesRouter.stack.find((l) => l.route && l.route.path === '/resolve');
|
||||
assert.ok(resolveLayer, '/resolve endpoint must exist');
|
||||
const resolveHandler = resolveLayer.route.stack[resolveLayer.route.stack.length - 1].handle;
|
||||
|
||||
it('search rejects empty or short query with 400', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = { query: { query: 'a' } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await searchHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('search rejects excessive query (>200 chars) with 400', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = { query: { query: 'A'.repeat(201) } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await searchHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('search normalizes Pelias GeoJSON features into place results with coordinates', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-places-key';
|
||||
const originalGet = axios.get;
|
||||
try {
|
||||
axios.get = async (url, config) => {
|
||||
assert.strictEqual(url, 'https://api.stadiamaps.com/geocoding/v1/autocomplete');
|
||||
assert.strictEqual(config?.params?.api_key, 'test-places-key');
|
||||
return {
|
||||
data: {
|
||||
features: [
|
||||
{
|
||||
properties: {
|
||||
id: 'venue_123',
|
||||
name: 'Skyline Boulevard',
|
||||
label: 'Skyline Boulevard, Oakland, CA',
|
||||
region: 'California',
|
||||
country: 'USA'
|
||||
},
|
||||
geometry: {
|
||||
coordinates: [-122.18, 37.81]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const req = { query: { query: 'Skyline' } };
|
||||
let responseData = null;
|
||||
const res = {
|
||||
json: (d) => { responseData = d; }
|
||||
};
|
||||
|
||||
await searchHandler(req, res);
|
||||
assert.ok(Array.isArray(responseData?.results));
|
||||
assert.strictEqual(responseData.results.length, 1);
|
||||
const place = responseData.results[0];
|
||||
assert.strictEqual(place.providerId, 'venue_123');
|
||||
assert.strictEqual(place.primaryText, 'Skyline Boulevard');
|
||||
assert.strictEqual(place.latitude, 37.81);
|
||||
assert.strictEqual(place.longitude, -122.18);
|
||||
} finally {
|
||||
axios.get = originalGet;
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolve rejects missing or empty placeId with 400', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = { query: { placeId: ' ' } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await resolveHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolve rejects excessive placeId (>200 chars) with 400', async () => {
|
||||
process.env.STADIA_API_KEY = 'test-key';
|
||||
try {
|
||||
const req = { query: { placeId: 'x'.repeat(201) } };
|
||||
let statusCode = null;
|
||||
let body = null;
|
||||
const res = {
|
||||
status: (c) => {
|
||||
statusCode = c;
|
||||
return { json: (b) => { body = b; } };
|
||||
}
|
||||
};
|
||||
await resolveHandler(req, res);
|
||||
assert.strictEqual(statusCode, 400);
|
||||
assert.strictEqual(body?.error, 'invalidRequest');
|
||||
} finally {
|
||||
delete process.env.STADIA_API_KEY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const app = require('../server');
|
||||
|
||||
describe('Waze Compatibility and Health Endpoints', () => {
|
||||
it('health endpoint returns legacy service name and status ok', async () => {
|
||||
// Test the health route handler directly
|
||||
const route = app._router.stack.find(
|
||||
(layer) => layer.route && layer.route.path && (layer.route.path === '/health' || layer.route.path.includes('/health'))
|
||||
);
|
||||
assert.ok(route, 'Health route should be registered');
|
||||
|
||||
let responseData = null;
|
||||
const req = {};
|
||||
const res = {
|
||||
json: (data) => {
|
||||
responseData = data;
|
||||
}
|
||||
};
|
||||
|
||||
route.route.stack[0].handle(req, res);
|
||||
|
||||
assert.strictEqual(responseData?.status, 'ok');
|
||||
assert.strictEqual(responseData?.service, 'MotorSignal Waze Community Relay');
|
||||
assert.ok(Array.isArray(responseData?.features));
|
||||
assert.ok(responseData.features.includes('police'));
|
||||
assert.ok(responseData.features.includes('traffic'));
|
||||
});
|
||||
|
||||
it('mounts legacy /alerts, /api/alerts, and /api/waze routes', () => {
|
||||
const mountedPaths = app._router.stack
|
||||
.filter((layer) => layer.name === 'router')
|
||||
.map((layer) => layer.regexp.toString());
|
||||
|
||||
// Express regex representations for paths
|
||||
const hasAlerts = mountedPaths.some((p) => p.includes('alerts'));
|
||||
const hasApiAlerts = mountedPaths.some((p) => p.includes('api'));
|
||||
|
||||
assert.ok(hasAlerts, '/alerts router should be mounted');
|
||||
assert.ok(hasApiAlerts, '/api/* router should be mounted');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
|
||||
class FakeRouteProvider implements RouteProvider {
|
||||
final bool shouldFail;
|
||||
final bool shouldTimeout;
|
||||
|
||||
FakeRouteProvider({
|
||||
this.shouldFail = false,
|
||||
this.shouldTimeout = false,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Fake Provider';
|
||||
|
||||
@override
|
||||
Future<RouteCalculationResult> calculateRoute(
|
||||
List<RouteStop> stops, {
|
||||
bool avoidHighways = false,
|
||||
bool avoidTolls = false,
|
||||
bool avoidFerries = false,
|
||||
}) async {
|
||||
if (shouldTimeout) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
throw RouteProviderException('providerTimeout', 'Fake timeout');
|
||||
}
|
||||
|
||||
if (shouldFail) {
|
||||
throw RouteProviderException('routeNotFound', 'Fake route not found');
|
||||
}
|
||||
|
||||
if (stops.length < 2) {
|
||||
throw RouteProviderException('invalidRequest', 'Too few stops');
|
||||
}
|
||||
if (stops.length > 25) {
|
||||
throw RouteProviderException('invalidRequest', 'Too many stops');
|
||||
}
|
||||
|
||||
final ordered = List<RouteStop>.from(stops);
|
||||
ordered.sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
final dist = MotorSignalRoute.calculateTotalDistanceMeters(ordered);
|
||||
final geometry = ordered.map((s) => s.latLng).toList();
|
||||
|
||||
return RouteCalculationResult(
|
||||
straightLineDistanceMeters: dist,
|
||||
estimatedDistanceMeters: dist * 1.2,
|
||||
estimatedDurationSeconds: (dist / 20).toInt(),
|
||||
geometry: geometry,
|
||||
isStraightLine: false,
|
||||
providerName: name,
|
||||
warnings: ['This is a fake route.'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResolvedPlace> resolvePlace(String placeId) async {
|
||||
if (shouldFail) throw RouteProviderException('placeNotFound', 'Fake not found');
|
||||
return ResolvedPlace(
|
||||
providerId: placeId,
|
||||
displayName: 'Fake Place',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
attribution: 'Fake Attribution',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PlaceSearchResult>> searchPlaces(
|
||||
String query, {
|
||||
AppLatLng? nearLocation,
|
||||
}) async {
|
||||
if (shouldFail) throw RouteProviderException('placeSearchUnavailable', 'Fake search error');
|
||||
if (query.length < 2) return [];
|
||||
|
||||
return [
|
||||
PlaceSearchResult(
|
||||
providerId: 'fake_1',
|
||||
primaryText: 'Fake Result 1 for $query',
|
||||
secondaryText: 'Fake City',
|
||||
attribution: 'Fake Attribution',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_models.dart';
|
||||
import 'fake_route_provider.dart';
|
||||
import 'package:motorcycle_dash/services/routes/route_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('FakeRouteProvider', () {
|
||||
test('enforces 25 stop maximum', () async {
|
||||
final provider = FakeRouteProvider();
|
||||
|
||||
final stops = List<RouteStop>.generate(26, (i) => RouteStop(
|
||||
id: 'stop_$i',
|
||||
displayName: 'Stop $i',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
order: i,
|
||||
stopType: RouteStopType.waypoint,
|
||||
));
|
||||
|
||||
expect(
|
||||
() => provider.calculateRoute(stops),
|
||||
throwsA(isA<RouteProviderException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces 2 stop minimum', () async {
|
||||
final provider = FakeRouteProvider();
|
||||
|
||||
final stops = [
|
||||
const RouteStop(
|
||||
id: 'stop_1',
|
||||
displayName: 'Stop 1',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
order: 0,
|
||||
stopType: RouteStopType.waypoint,
|
||||
)
|
||||
];
|
||||
|
||||
expect(
|
||||
() => provider.calculateRoute(stops),
|
||||
throwsA(isA<RouteProviderException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('calculates successfully', () async {
|
||||
final provider = FakeRouteProvider();
|
||||
|
||||
final stops = List<RouteStop>.generate(3, (i) => RouteStop(
|
||||
id: 'stop_$i',
|
||||
displayName: 'Stop $i',
|
||||
latitude: i.toDouble(),
|
||||
longitude: i.toDouble(),
|
||||
order: i,
|
||||
stopType: RouteStopType.waypoint,
|
||||
));
|
||||
|
||||
final result = await provider.calculateRoute(stops);
|
||||
expect(result.geometry.length, 3);
|
||||
expect(result.isStraightLine, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/services/routes/valhalla_polyline_decoder.dart';
|
||||
|
||||
void main() {
|
||||
group('ValhallaPolylineDecoder', () {
|
||||
test('handles empty string', () {
|
||||
final points = ValhallaPolylineDecoder.decode('');
|
||||
expect(points, isEmpty);
|
||||
});
|
||||
|
||||
test('decodes single point with precision 6', () {
|
||||
// Deterministic fixture for (37.774900, -122.419400)
|
||||
final points = ValhallaPolylineDecoder.decode('gbr`gAnk{nhF');
|
||||
expect(points.length, 1);
|
||||
expect(points[0].latitude, closeTo(37.7749, 1e-5));
|
||||
expect(points[0].longitude, closeTo(-122.4194, 1e-5));
|
||||
});
|
||||
|
||||
test('decodes multiple points with positive deltas (SF to Oakland)', () {
|
||||
// SF (37.774900, -122.419400) -> Oakland (37.804400, -122.271200)
|
||||
final points = ValhallaPolylineDecoder.decode('gbr`gAnk{nhFwrx@om`H');
|
||||
expect(points.length, 2);
|
||||
expect(points[0].latitude, closeTo(37.7749, 1e-5));
|
||||
expect(points[0].longitude, closeTo(-122.4194, 1e-5));
|
||||
expect(points[1].latitude, closeTo(37.8044, 1e-5));
|
||||
expect(points[1].longitude, closeTo(-122.2712, 1e-5));
|
||||
});
|
||||
|
||||
test('decodes multiple points with negative lat delta (SF to San Jose)', () {
|
||||
// SF (37.774900, -122.419400) -> San Jose (37.338200, -121.886300)
|
||||
final points = ValhallaPolylineDecoder.decode('gbr`gAnk{nhFv|sYwep_@');
|
||||
expect(points.length, 2);
|
||||
expect(points[0].latitude, closeTo(37.7749, 1e-5));
|
||||
expect(points[0].longitude, closeTo(-122.4194, 1e-5));
|
||||
expect(points[1].latitude, closeTo(37.3382, 1e-5));
|
||||
expect(points[1].longitude, closeTo(-121.8863, 1e-5));
|
||||
});
|
||||
|
||||
test('throws FormatException on truncated polyline', () {
|
||||
expect(
|
||||
() => ValhallaPolylineDecoder.decode('gbr`gA'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException on invalid characters (ascii < 63)', () {
|
||||
expect(
|
||||
() => ValhallaPolylineDecoder.decode('gbr\x00gAnk'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when coordinates exceed geographic bounds', () {
|
||||
// Encoded value that unpacks to latitude > 90
|
||||
// 95.0 * 1e6 = 95000000
|
||||
expect(
|
||||
() => ValhallaPolylineDecoder.decode('____~_~_~_~_~_'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
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',
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue