feat: add real motorcycle routing provider foundation

This commit is contained in:
Developer 2026-09-05 17:33:58 -04:00
parent 2319222f22
commit 9958a8a600
18 changed files with 5045 additions and 310 deletions

View File

@ -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,
),
];
}
return [
PlaceSearchResult(
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,
);
}
}

View File

@ -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;
}
}

View File

@ -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',
);
}
}
}

View File

@ -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 };

View File

@ -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

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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', () => {
console.log(`====================================================`);
console.log(` 🏍️ MotorSignal 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(`====================================================`);
});
if (require.main === module) {
app.listen(PORT, '0.0.0.0', () => {
console.log(`====================================================`);
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;

View File

@ -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;
}
});
});

View File

@ -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;
}
});
});

View File

@ -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');
});
});

View File

@ -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',
),
];
}
}

View File

@ -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);
});
});
}

View File

@ -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>()),
);
});
});
}

View File

@ -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',
)),
);
});
});
}