diff --git a/Core/Resgrid.Config/MappingConfig.cs b/Core/Resgrid.Config/MappingConfig.cs
index 99ef8ab02..bcd36a064 100644
--- a/Core/Resgrid.Config/MappingConfig.cs
+++ b/Core/Resgrid.Config/MappingConfig.cs
@@ -112,7 +112,11 @@ public static class MappingConfig
/// Routing provider: "valhalla", "mapbox", "google", "osrm" or "estimate" (straight line). Today's behaviour is "google".
public static string RoutingProvider = "google";
- /// Provider used when the primary fails or times out; the straight-line estimate always remains the last resort.
+ ///
+ /// Provider used when the primary fails or times out; the straight-line estimate always remains the last resort. When
+ /// Valhalla is the primary or the fallback, Mapbox (MapboxRoutingAccessToken, else the web map's Mapbox token) is always
+ /// tried before the estimate.
+ ///
public static string RoutingFallbackProvider = "estimate";
/// Base URL of a Valhalla server, e.g. http://valhalla:8002 (private network addresses are fine).
@@ -132,8 +136,8 @@ public static class MappingConfig
///
/// Where the Valhalla server has map data, as "west,south,east,north" boxes in degrees separated by ";". A route,
- /// matrix or optimization with any point outside every box goes straight to RoutingFallbackProvider without calling
- /// Valhalla. Empty: everywhere. North America and Europe (e.g. SimpleRouting): "-170,14,-50,84;-32,27,45,82".
+ /// matrix or optimization with any point outside every box goes straight to RoutingFallbackProvider (then Mapbox) without
+ /// calling Valhalla. Empty: everywhere. North America and Europe (e.g. SimpleRouting): "-170,14,-50,84;-32,27,45,82".
///
public static string ValhallaCoverage = "";
@@ -143,7 +147,11 @@ public static class MappingConfig
/// Most road-closure points in one Valhalla route; match the server's service_limits.max_exclude_locations (stock Valhalla 50, the setup kit raises it to 200).
public static int ValhallaMaxExcludePoints = 50;
- /// Server-side Mapbox token for Directions, Matrix and Optimization; may be a secret (sk.) token and is never sent to clients.
+ ///
+ /// Server-side Mapbox token for Directions, Matrix and Optimization; may be a secret (sk.) token and is never sent to
+ /// clients. Empty: the web app's Mapbox map token (WebsiteMapboxAccessToken, then WebsiteMapboxKey) is used, sent with
+ /// SystemBehaviorConfig.ResgridBaseUrl as the Referer so a URL-restricted public token is accepted.
+ ///
public static string MapboxRoutingAccessToken = "";
/// Use live traffic where the provider has it (Mapbox driving-traffic, Google TRAFFIC_AWARE) for the DrivingTraffic profile and drive times.
diff --git a/Core/Resgrid.Services/RoutingService.cs b/Core/Resgrid.Services/RoutingService.cs
index 974603862..690867dd1 100644
--- a/Core/Resgrid.Services/RoutingService.cs
+++ b/Core/Resgrid.Services/RoutingService.cs
@@ -19,7 +19,7 @@ namespace Resgrid.Services
///
/// Routes, matrices and stop-order optimization through the install's routing provider
/// (maplibre-mapping-routing-navigation-plan.md §6.1): the provider MappingConfig.RoutingProvider names, then
- /// RoutingFallbackProvider, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are
+ /// RoutingFallbackProvider, then Mapbox when Valhalla is either of those, then the straight-line estimate. Each call is bounded by RoutingTimeoutMs; results are
/// cached in Redis by provider, profile, vehicle and coordinates (30 days, 5 minutes when traffic-aware); a provider
/// that just failed is skipped for a short back-off so a broken key or server does not slow every request.
/// A department with its own routing account (setting 125) has it tried first, then the system chain. Back-off, cache
@@ -179,8 +179,8 @@ private IRoutingProvider Find(string name)
}
///
- /// The department's own account (if any), then primary, fallback, estimate — skipping duplicates, unconfigured
- /// providers and ones in back-off.
+ /// The department's own account (if any), then primary, fallback, Mapbox when Valhalla is one of those, estimate —
+ /// skipping duplicates, unconfigured providers and ones in back-off.
///
private async Task> ChainAsync(int departmentId)
{
@@ -200,19 +200,65 @@ void Add(Link link)
Link System(IRoutingProvider provider) => provider == null ? null : new Link { Provider = provider, Key = provider.Name };
- Add(await DepartmentLinkAsync(departmentId));
- Add(System(Find(MappingConfig.RoutingProvider)));
- Add(System(Find(MappingConfig.RoutingFallbackProvider)));
+ // The estimate always answers last, even when RoutingProvider or RoutingFallbackProvider names it.
+ Link Routed(string name)
+ {
+ var provider = Find(name);
+ return provider == null || provider.Name == RoutingProviderNames.Estimate ? null : System(provider);
+ }
+
+ var department = await DepartmentLinkAsync(departmentId);
+ var primary = Routed(MappingConfig.RoutingProvider);
+ var fallback = Routed(MappingConfig.RoutingFallbackProvider);
+
+ Add(department);
+ Add(primary);
+ Add(fallback);
+
+ // Valhalla (a hosted plan's per-second and daily limits, a server that is down, a point off its map) hands over to
+ // the install's Mapbox before the straight-line estimate, whatever RoutingFallbackProvider says. Decided on what is
+ // configured, not on what is in the chain: while Valhalla sits out its back-off Mapbox must still answer.
+ if (UsesValhalla(department, primary, fallback))
+ Add(System(Find(RoutingProviderNames.Mapbox)));
+
Add(System(Estimate));
return chain;
}
+ private static bool UsesValhalla(params Link[] links) =>
+ links.Any(l => l?.Provider != null && l.Provider.Name == RoutingProviderNames.Valhalla && l.Provider.IsConfigured);
+
+ /// What answers after in this request's chain, for the log.
+ private static string Next(IReadOnlyList chain, Link link)
+ {
+ var rest = (chain ?? new List()).SkipWhile(l => !ReferenceEquals(l, link)).Skip(1)
+ .Select(l => l.Provider.Name == RoutingProviderNames.Estimate ? "the straight-line estimate" : l.DepartmentAccount ? $"{l.Provider.Name} (department account)" : l.Provider.Name)
+ .ToList();
+
+ return rest.Count == 0 ? "no other provider" : string.Join(" then ", rest);
+ }
+
+ ///
+ /// Valhalla without a usable system Mapbox goes straight to the estimate when it fails: say how to close that gap.
+ ///
+ private string MapboxGap(Link link)
+ {
+ if (link?.Provider?.Name != RoutingProviderNames.Valhalla)
+ return string.Empty;
+
+ var mapbox = Find(RoutingProviderNames.Mapbox);
+ return mapbox != null && mapbox.IsConfigured
+ ? string.Empty
+ : " Set MappingConfig.MapboxRoutingAccessToken (or a Mapbox WebsiteMapboxAccessToken) so Mapbox answers before the straight-line estimate.";
+ }
+
public async Task GetRouteAsync(RouteRequest request, CancellationToken cancellationToken = default)
{
if (request?.Waypoints == null || request.Waypoints.Count < 2)
return new RouteResult { Error = "A route needs at least two points" };
- foreach (var link in await ChainAsync(request.DepartmentId))
+ var chain = await ChainAsync(request.DepartmentId);
+ foreach (var link in chain)
{
var provider = link.Provider;
var trafficAware = IsTrafficAware(provider, request.Profile);
@@ -267,9 +313,9 @@ public async Task GetRouteAsync(RouteRequest request, CancellationT
}
if (result?.RequestRejected == true)
- RecordRejection(link, result.Error);
+ RecordRejection(chain, link, result.Error);
else
- RecordFailure(link, result?.Error);
+ RecordFailure(chain, link, result?.Error);
}
return new RouteResult { Error = "No routing provider returned a route" };
@@ -305,7 +351,8 @@ public async Task GetMatrixAsync(RouteMatrixRequest request,
var pending = Enumerable.Range(0, origins).ToList();
string provided = null;
- foreach (var link in await ChainAsync(request.DepartmentId))
+ var chain = await ChainAsync(request.DepartmentId);
+ foreach (var link in chain)
{
var provider = link.Provider;
var trafficAware = IsTrafficAware(provider, request.Profile);
@@ -378,9 +425,9 @@ public async Task GetMatrixAsync(RouteMatrixRequest request,
// One origin outside the provider's coverage fails its whole batch; the next provider takes the
// remaining origins for this request only.
if (result?.RequestRejected == true)
- RecordRejection(link, result.Error);
+ RecordRejection(chain, link, result.Error);
else
- RecordFailure(link, result?.Error);
+ RecordFailure(chain, link, result?.Error);
failed = true;
break;
}
@@ -419,7 +466,8 @@ public async Task OptimizeAsync(RouteOptimizationReques
// Each provider in the chain that can optimize this many stops gets its turn (a department account that can't, or
// that fails, hands over to the install's providers, as routes do); the matrix heuristic answers when none does.
- foreach (var link in (await ChainAsync(request.DepartmentId)).Where(l => l.Provider.Name != RoutingProviderNames.Estimate))
+ var chain = await ChainAsync(request.DepartmentId);
+ foreach (var link in chain.Where(l => l.Provider.Name != RoutingProviderNames.Estimate))
{
var provider = link.Provider;
if (!provider.Capabilities.Optimize || request.Waypoints.Count > provider.Capabilities.MaxOptimizeWaypoints)
@@ -444,7 +492,7 @@ public async Task OptimizeAsync(RouteOptimizationReques
catch (Exception ex)
{
await RecordUsageAsync(link, RoutingUsageOperation.Optimize, false, false, false);
- RecordFailure(link, ex.Message);
+ RecordFailure(chain, link, ex.Message);
}
}
@@ -494,7 +542,7 @@ private static bool IsTrafficAware(IRoutingProvider provider, RoutingProfile pro
return profile == RoutingProfile.DrivingTraffic && MappingConfig.RoutingTrafficAware && provider.Capabilities.Traffic;
}
- private static void RecordFailure(Link link, string error)
+ private void RecordFailure(IReadOnlyList chain, Link link, string error)
{
var provider = link?.Provider;
if (provider == null || provider.Name == RoutingProviderNames.Estimate)
@@ -515,14 +563,14 @@ private static void RecordFailure(Link link, string error)
return;
}
- Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {MappingConfig.RoutingFallbackProvider} then the straight-line estimate.");
+ Logging.LogError($"Routing provider '{provider.Name}' failed: {error}. Check MappingConfig.RoutingProvider and the provider's settings ({SettingsFor(provider.Name)}); falling back to {Next(chain, link)}.{MapboxGap(link)}");
}
///
/// The provider is up but cannot route this request (outside its coverage, no road near a point, over its limits):
/// the next provider answers it and the provider stays in use for everything else. Logged at most every 10 minutes.
///
- private static void RecordRejection(Link link, string error)
+ private void RecordRejection(IReadOnlyList chain, Link link, string error)
{
var provider = link?.Provider;
if (provider == null || provider.Name == RoutingProviderNames.Estimate)
@@ -534,7 +582,7 @@ private static void RecordRejection(Link link, string error)
return;
LastErrorLogged[key] = now;
- Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {MappingConfig.RoutingFallbackProvider} answered it instead. The provider stays in use for other requests.");
+ Logging.LogInfo($"Routing provider '{provider.Name}' could not route a request ({error}); {Next(chain, link)} answered it instead. The provider stays in use for other requests.{MapboxGap(link)}");
}
private static string SettingsFor(string provider)
@@ -544,7 +592,7 @@ private static string SettingsFor(string provider)
case RoutingProviderNames.Valhalla:
return "MappingConfig.ValhallaUrl, MappingConfig.ValhallaApiKey, MappingConfig.ValhallaAuthScheme";
case RoutingProviderNames.Mapbox:
- return "MappingConfig.MapboxRoutingAccessToken";
+ return "MappingConfig.MapboxRoutingAccessToken, else MappingConfig.WebsiteMapboxAccessToken / WebsiteMapboxKey sent with SystemBehaviorConfig.ResgridBaseUrl as Referer";
case RoutingProviderNames.Google:
return "MappingConfig.GoogleMapsApiKey, MappingConfig.GoogleRoutingApi";
case RoutingProviderNames.Osrm:
diff --git a/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs b/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs
index 89a56b1a7..02f43a96b 100644
--- a/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs
+++ b/Providers/Resgrid.Providers.Geo/Routing/MapboxRoutingProvider.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Linq;
using System.Net;
+using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -14,8 +15,9 @@ namespace Resgrid.Providers.GeoLocationProvider.Routing
{
///
/// Mapbox Directions v5, Matrix v1 and Optimization v1 (plan §6.1) with the server-side token
- /// (MappingConfig.MapboxRoutingAccessToken). Live traffic and incidents on driving-traffic. Applies vehicle height,
- /// width and weight (max_height/max_width/max_weight); Mapbox has no length, axle or hazmat restrictions.
+ /// (MappingConfig.MapboxRoutingAccessToken), else the web app's Mapbox map token. Live traffic and incidents on
+ /// driving-traffic. Applies vehicle height, width and weight (max_height/max_width/max_weight); Mapbox has no length,
+ /// axle or hazmat restrictions.
///
public class MapboxRoutingProvider : IRoutingProvider
{
@@ -39,7 +41,39 @@ public IRoutingProvider WithCredentials(RoutingProviderCredentials credentials)
return credentials == null ? null : new MapboxRoutingProvider(credentials);
}
- private string AccessToken => _department != null ? _department.ApiKey : MappingConfig.MapboxRoutingAccessToken;
+ private string AccessToken => _department != null ? _department.ApiKey : InstallToken().Token;
+
+ ///
+ /// The install's token for Directions, Matrix and Optimization: MapboxRoutingAccessToken, else the web app's Mapbox
+ /// map token (WebsiteMapboxAccessToken, then WebsiteMapboxKey), which those APIs accept as well — an install with
+ /// Mapbox maps routes on Mapbox without a separate routing token. Only a Mapbox token (pk. or sk.) is borrowed from the
+ /// web settings, since WebsiteMapboxKey may hold another tile service's key. The web map token is public and usually
+ /// URL-restricted to the web app, which Mapbox checks against the Referer, so it comes with the install's own web
+ /// address to send as one; the routing token comes with none. Token null: no Mapbox token at all.
+ ///
+ public static (string Token, Uri Referer) InstallToken()
+ {
+ var routing = MappingConfig.MapboxRoutingAccessToken?.Trim();
+ if (!string.IsNullOrEmpty(routing))
+ return (routing, null);
+
+ var website = new[] { MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey }
+ .Select(t => t?.Trim())
+ .FirstOrDefault(t => !string.IsNullOrEmpty(t) &&
+ (t.StartsWith("pk.", StringComparison.Ordinal) || t.StartsWith("sk.", StringComparison.Ordinal)));
+ if (website == null)
+ return (null, null);
+
+ Uri.TryCreate(SystemBehaviorConfig.ResgridBaseUrl?.Trim().TrimEnd('/') + "/", UriKind.Absolute, out var site);
+ return (website, site);
+ }
+
+ /// Sets the Referer the install's web map token needs; nothing for a routing or department token.
+ private Action Referer()
+ {
+ var referer = _department == null ? InstallToken().Referer : null;
+ return referer == null ? null : request => request.Headers.Referrer = referer;
+ }
public string Name => RoutingProviderNames.Mapbox;
@@ -89,7 +123,7 @@ public async Task GetRouteAsync(RouteRequest request, CancellationT
AppendStopIndexes(query, request.Waypoints);
var url = $"{BaseUrl}/directions/v5/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}";
- var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken);
+ var (_, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer());
using (document)
{
@@ -129,7 +163,7 @@ public async Task GetMatrixAsync(RouteMatrixRequest request,
query.Append("&sources=").Append(sources).Append("&destinations=").Append(destinations);
var url = $"{BaseUrl}/directions-matrix/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(coordinates)}{query}";
- var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken);
+ var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer());
using (document)
{
@@ -164,7 +198,7 @@ public async Task OptimizeAsync(RouteOptimizationReques
query.Append("&source=first&destination=last&roundtrip=false&overview=false");
var url = $"{BaseUrl}/optimized-trips/v1/mapbox/{profile}/{RoutingHttp.LonLatPath(request.Waypoints)}{query}";
- var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken);
+ var (ok, status, document) = await RoutingHttp.GetJsonAsync(url, cancellationToken, Referer());
using (document)
{
diff --git a/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs b/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs
index 4b9601691..a1970fa02 100644
--- a/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/Routing/RoutingServiceTests.cs
@@ -1,5 +1,8 @@
+using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -34,6 +37,7 @@ public void SetUp()
_cacheEnabled = SystemBehaviorConfig.CacheEnabled;
SystemBehaviorConfig.CacheEnabled = false;
MappingConfig.NavigationEnabled = true;
+ ClearProviderHealth();
}
[TearDown]
@@ -43,6 +47,20 @@ public void TearDown()
MappingConfig.RoutingFallbackProvider = _fallback;
MappingConfig.NavigationEnabled = _navigation;
SystemBehaviorConfig.CacheEnabled = _cacheEnabled;
+ ClearProviderHealth();
+ }
+
+ ///
+ /// A failed provider is benched for 60 s in process-wide state; clear it so one test's failure never decides
+ /// another's chain.
+ ///
+ private static void ClearProviderHealth()
+ {
+ foreach (var name in new[] { "ProviderBackoffUntil", "LastErrorLogged" })
+ {
+ var field = typeof(RoutingService).GetField(name, BindingFlags.NonPublic | BindingFlags.Static)!;
+ ((ConcurrentDictionary)field.GetValue(null)!).Clear();
+ }
}
private static Mock Provider(string name, bool navigation, RouteResult route = null, bool configured = true)
@@ -227,6 +245,200 @@ public async Task rejected_matrix_goes_to_the_fallback_without_benching_the_prov
second.Provider.Should().Be(RoutingProviderNames.Estimate);
valhalla.Verify(p => p.GetMatrixAsync(It.IsAny(), It.IsAny()), Times.Exactly(2));
}
+
+ [Test]
+ public async Task failed_valhalla_falls_back_to_mapbox_before_the_estimate_even_with_an_estimate_fallback()
+ {
+ // Production (2026-11-13): SimpleRouting answered 429 and the dispatch recommendation got the straight-line estimate,
+ // because RoutingFallbackProvider was left at "estimate" although the install has a Mapbox routing token.
+ MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate;
+ var valhalla = Provider(RoutingProviderNames.Valhalla, true, new RouteResult { Successful = false, Error = "429: No route" });
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 510 });
+ var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var first = await service.GetRouteAsync(TwoPoints());
+ var second = await service.GetRouteAsync(TwoPoints());
+
+ first.Successful.Should().BeTrue();
+ first.Provider.Should().Be(RoutingProviderNames.Mapbox);
+ first.DurationSeconds.Should().Be(510);
+ second.Provider.Should().Be(RoutingProviderNames.Mapbox, "Mapbox keeps answering while Valhalla sits out its back-off");
+ valhalla.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+
+ [Test]
+ public async Task rejected_valhalla_route_goes_to_mapbox_before_the_estimate()
+ {
+ MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate;
+ var valhalla = Provider(RoutingProviderNames.Valhalla, true,
+ new RouteResult { Successful = false, Error = "Outside MappingConfig.ValhallaCoverage", RequestRejected = true, NotSent = true });
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 300 });
+ var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var route = await service.GetRouteAsync(TwoPoints());
+
+ route.Provider.Should().Be(RoutingProviderNames.Mapbox);
+ route.DurationSeconds.Should().Be(300);
+ }
+
+ [Test]
+ public async Task a_configured_fallback_is_tried_before_the_added_mapbox()
+ {
+ MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Osrm;
+ var valhalla = Provider(RoutingProviderNames.Valhalla, true);
+ var osrm = Provider(RoutingProviderNames.Osrm, false);
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true, DurationSeconds = 300 });
+ var service = new RoutingService(new[] { valhalla.Object, osrm.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var route = await service.GetRouteAsync(TwoPoints());
+
+ route.Provider.Should().Be(RoutingProviderNames.Mapbox);
+ osrm.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task failed_valhalla_without_a_mapbox_token_falls_back_to_the_estimate()
+ {
+ MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate;
+ var valhalla = Provider(RoutingProviderNames.Valhalla, true, new RouteResult { Successful = false, Error = "503: unavailable" });
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true }, configured: false);
+ var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var route = await service.GetRouteAsync(TwoPoints());
+
+ route.Successful.Should().BeTrue();
+ route.Provider.Should().Be(RoutingProviderNames.Estimate);
+ mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task mapbox_is_not_added_when_valhalla_is_not_in_use()
+ {
+ // Mapbox is billed per call: an install on Google with an estimate fallback keeps exactly that chain.
+ MappingConfig.RoutingProvider = RoutingProviderNames.Google;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate;
+ var google = Provider(RoutingProviderNames.Google, false);
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true, new RouteResult { Successful = true });
+ var service = new RoutingService(new[] { google.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var route = await service.GetRouteAsync(TwoPoints());
+
+ route.Provider.Should().Be(RoutingProviderNames.Estimate);
+ mapbox.Verify(p => p.GetRouteAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task failed_valhalla_matrix_falls_back_to_the_mapbox_matrix()
+ {
+ MappingConfig.RoutingProvider = RoutingProviderNames.Valhalla;
+ MappingConfig.RoutingFallbackProvider = RoutingProviderNames.Estimate;
+ var valhalla = Provider(RoutingProviderNames.Valhalla, true);
+ valhalla.Setup(p => p.GetMatrixAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new RouteMatrixResult { Successful = false, Error = "Matrix failed (429)" });
+ var mapbox = Provider(RoutingProviderNames.Mapbox, true);
+ mapbox.Setup(p => p.GetMatrixAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((RouteMatrixRequest r, CancellationToken _) => new RouteMatrixResult
+ {
+ Successful = true,
+ Provider = RoutingProviderNames.Mapbox,
+ Durations = r.Origins.Select(_ => r.Destinations.Select(__ => (double?)240).ToArray()).ToArray(),
+ Distances = r.Origins.Select(_ => r.Destinations.Select(__ => (double?)3000).ToArray()).ToArray()
+ });
+ var service = new RoutingService(new[] { valhalla.Object, mapbox.Object, new EstimateRoutingProvider() }, null, null, null);
+
+ var matrix = await service.GetMatrixAsync(new RouteMatrixRequest
+ {
+ Origins = new List { new RoutingWaypoint(39.53, -119.81), new RoutingWaypoint(39.60, -119.80) },
+ Destinations = new List { new RoutingWaypoint(39.54, -119.75) }
+ });
+
+ matrix.Successful.Should().BeTrue();
+ matrix.Provider.Should().Be(RoutingProviderNames.Mapbox);
+ matrix.Durations[0][0].Should().Be(240);
+ matrix.Durations[1][0].Should().Be(240);
+ }
+ }
+
+ ///
+ /// Mapbox routing without a routing token borrows the web app's Mapbox map token (Directions, Matrix and Optimization
+ /// accept a public token), with the install's web address as Referer for a URL-restricted token.
+ ///
+ [TestFixture]
+ public class MapboxInstallTokenTests
+ {
+ private (string Routing, string WebsiteAccess, string WebsiteKey, string BaseUrl) _saved;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _saved = (MappingConfig.MapboxRoutingAccessToken, MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey, SystemBehaviorConfig.ResgridBaseUrl);
+ MappingConfig.MapboxRoutingAccessToken = MappingConfig.WebsiteMapboxAccessToken = MappingConfig.WebsiteMapboxKey = string.Empty;
+ SystemBehaviorConfig.ResgridBaseUrl = "https://app.example.org";
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ (MappingConfig.MapboxRoutingAccessToken, MappingConfig.WebsiteMapboxAccessToken, MappingConfig.WebsiteMapboxKey, SystemBehaviorConfig.ResgridBaseUrl) = _saved;
+ }
+
+ [Test]
+ public void the_routing_token_wins_and_needs_no_referer()
+ {
+ MappingConfig.MapboxRoutingAccessToken = "sk.routing";
+ MappingConfig.WebsiteMapboxAccessToken = "pk.website";
+
+ var (token, referer) = MapboxRoutingProvider.InstallToken();
+
+ token.Should().Be("sk.routing");
+ referer.Should().BeNull();
+ }
+
+ [Test]
+ public void without_a_routing_token_the_web_map_token_answers_with_the_install_address_as_referer()
+ {
+ MappingConfig.WebsiteMapboxAccessToken = " pk.website ";
+ SystemBehaviorConfig.ResgridBaseUrl = "https://app.example.org/";
+
+ var (token, referer) = MapboxRoutingProvider.InstallToken();
+
+ token.Should().Be("pk.website");
+ referer.Should().Be(new Uri("https://app.example.org/"));
+ new MapboxRoutingProvider().IsConfigured.Should().BeTrue();
+ }
+
+ [Test]
+ public void the_older_website_key_is_used_when_the_access_token_is_blank()
+ {
+ MappingConfig.WebsiteMapboxKey = "pk.older";
+
+ MapboxRoutingProvider.InstallToken().Token.Should().Be("pk.older");
+ }
+
+ [Test]
+ public void a_non_mapbox_website_key_is_never_sent_to_mapbox()
+ {
+ // WebsiteMapboxKey has held MapTiler keys on Leaflet installs; sending one to Mapbox only earns a 401 and a back-off.
+ MappingConfig.WebsiteMapboxKey = "maptiler-abc123";
+
+ MapboxRoutingProvider.InstallToken().Token.Should().BeNull();
+ new MapboxRoutingProvider().IsConfigured.Should().BeFalse();
+ }
+
+ [Test]
+ public void a_department_account_uses_only_its_own_key()
+ {
+ MappingConfig.WebsiteMapboxAccessToken = "pk.website";
+
+ new MapboxRoutingProvider().WithCredentials(new RoutingProviderCredentials { Provider = RoutingProviderNames.Mapbox, ApiKey = "" })
+ .IsConfigured.Should().BeFalse("a department's blank key never borrows the install's web token");
+ }
}
[TestFixture]