Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions Areas/User/Controllers/LocationProviderSettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ public async Task<IActionResult> ChooseProvider(LocationProviderChoiceInput inpu
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Challenge();
var capabilityValid = TryParseCapability(input.Capability, out var capability);
var providerValid = TryParseOptionalProvider(input.ProviderKey, out var provider);
if (!capabilityValid || !providerValid)
ModelState.AddModelError(string.Empty, "Choose a supported capability and provider.");
if (!ModelState.IsValid || setup == null) return View("Index", await BuildAsync(userId, cancellationToken));
var capability = Enum.Parse<PersonalProviderCapability>(input.Capability);
var provider = string.IsNullOrEmpty(input.ProviderKey) ? (PersonalLocationProvider?)null : ParseProvider(input.ProviderKey);
var result = await setup.ChooseAsync(userId, capability, provider, cancellationToken);
TempData["ProviderStatus"] = result == ProviderChoiceResult.Success
? $"{capability} provider choice saved." : "Verify this provider for the capability before selecting it.";
Expand Down Expand Up @@ -272,6 +274,30 @@ private static PersonalProviderVerification CurrentVerification(
private static PersonalLocationProvider ParseProvider(string key) => key switch
{ "geoapify" => PersonalLocationProvider.Geoapify, "mapbox" => PersonalLocationProvider.Mapbox, _ => throw new ArgumentOutOfRangeException(nameof(key)) };

/// <summary>Accepts only the two capability values posted by this settings page.</summary>
private static bool TryParseCapability(string value, out PersonalProviderCapability capability)
{
capability = value switch
{
"Geocoding" => PersonalProviderCapability.Geocoding,
"Routing" => PersonalProviderCapability.Routing,
_ => default
};
return value is "Geocoding" or "Routing";
}

/// <summary>Accepts no provider or one exact supported provider key.</summary>
private static bool TryParseOptionalProvider(string value, out PersonalLocationProvider? provider)
{
provider = value switch
{
"geoapify" => PersonalLocationProvider.Geoapify,
"mapbox" => PersonalLocationProvider.Mapbox,
_ => null
};
return string.IsNullOrEmpty(value) || provider != null;
}

/// <summary>Maps bounded request-local verification detail to credential-free presentation.</summary>
internal static string GeoapifyVerificationMessage(PersonalProviderCapability capability, GeoapifyVerificationOutcome outcome)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public sealed class LocationProviderSettingsViewModel
/// <summary>Accepts a credential replacement without changing provider choice implicitly.</summary>
public sealed class LocationProviderCredentialInput
{
[Required, RegularExpression("geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty;
[Required, RegularExpression("^(?:geoapify|mapbox)$")] public string ProviderKey { get; set; } = string.Empty;
[Required, DataType(DataType.Password), StringLength(2048), RegularExpression(@"^[^\s\p{Cc}]*$",
ErrorMessage = "Credentials cannot contain whitespace or control characters.")]
public string ReplacementCredential { get; set; } = string.Empty;
Expand All @@ -26,8 +26,8 @@ public sealed class LocationProviderCredentialInput
/// <summary>Accepts one capability-oriented provider choice.</summary>
public sealed class LocationProviderChoiceInput
{
[Required, RegularExpression("Geocoding|Routing")] public string Capability { get; set; } = string.Empty;
[RegularExpression("|geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty;
[Required, RegularExpression("^(?:Geocoding|Routing)$")] public string Capability { get; set; } = string.Empty;
[StringLength(32)] public string ProviderKey { get; set; } = string.Empty;
}

/// <summary>Presents bounded profile, capability, and provider-native usage state.</summary>
Expand All @@ -45,7 +45,7 @@ public sealed record LocationProviderProfileViewModel(
/// <summary>Accepts explicit profile replacement/authorization and independent selection.</summary>
public sealed class LocationProviderProfileInput
{
[Required, RegularExpression("geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty;
[Required, RegularExpression("^(?:geoapify|mapbox)$")] public string ProviderKey { get; set; } = string.Empty;
[DataType(DataType.Password), StringLength(2048), RegularExpression(@"^[^\s\p{Cc}]*$",
ErrorMessage = "Credentials cannot contain whitespace or control characters.")]
public string? ReplacementCredential { get; set; }
Expand All @@ -58,7 +58,7 @@ public sealed class LocationProviderProfileInput
/// <summary>Accepts one bounded provider-native guard setting.</summary>
public sealed class LocationProviderGuardInput
{
[Required, RegularExpression("geoapify|mapbox-permanent|mapbox-directions")] public string GuardKey { get; set; } = string.Empty;
[Required, RegularExpression("^(?:geoapify|mapbox-permanent|mapbox-directions)$")] public string GuardKey { get; set; } = string.Empty;
public bool Enabled { get; set; }
[Range(0, 10_000_000)] public int Limit { get; set; }
}
Expand Down
6 changes: 3 additions & 3 deletions Areas/User/Views/LocationProviderSettings/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{ <div class="alert alert-danger">Legacy Mapbox migration needs explicit recovery. No provider contact is authorized and no stored value was removed.</div> }
<div class="alert alert-info">Wayfarer records only its own contacts. Other applications can consume the provider account allowance; use a dedicated Wayfarer key when possible. Multiple keys may still share one provider allowance.</div>
@if (TempData["ProviderStatus"] is string providerStatus) { <div class="alert alert-info">@providerStatus</div> }
<div asp-validation-summary="All" class="text-danger"></div>
@foreach (var profile in Model.Profiles)
{
<section class="card mb-4"><div class="card-body">
Expand All @@ -16,13 +17,12 @@
<form asp-action="SaveCredential" method="post">
<input name="ProviderKey" value="mapbox" type="hidden" />
<label for="mapbox-credential" class="form-label">Replacement credential</label><input id="mapbox-credential" name="ReplacementCredential" type="password" autocomplete="new-password" class="form-control" required />
<div class="form-text">Submit only to replace the credential. Replacement clears consent, verification, and active selections.</div>
<div class="form-text">Submit only to replace the credential. Replacement clears consent and verification; saved provider choices remain blocked until reverified.</div>
<button class="btn btn-outline-primary mt-2" type="submit">Save credential</button>
</form>
<h3 class="h5 mt-3">Permanent Geocoding consent</h3>
<p><strong>Consent:</strong> @(profile.PermanentConsentCurrent ? $"current (version {profile.PermanentConsentVersion}, {profile.PermanentConsentedAt:u})" : "missing")</p>
<form asp-action="ConsentMapboxPermanent" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-check"><input id="storage-ack" name="StorageAcknowledged" value="true" type="checkbox" class="form-check-input" /><label for="storage-ack" class="form-check-label">I choose stored Mapbox Permanent Geocoding enrichment.</label></div>
<div class="form-check"><input id="billing-ack" name="BillingAcknowledged" value="true" type="checkbox" class="form-check-input" /><label for="billing-ack" class="form-check-label">I understand Permanent Geocoding is separately billed and may incur charges.</label></div>
<div class="form-check"><input id="eligibility-ack" name="BillingEligibilityAcknowledged" value="true" type="checkbox" class="form-check-input" /><label for="eligibility-ack" class="form-check-label">My Mapbox account has an eligible credit card or enterprise contract.</label></div>
Expand All @@ -39,7 +39,7 @@
<form asp-action="SaveCredential" method="post">
<input name="ProviderKey" value="@profile.ProviderKey" type="hidden" />
<label for="geoapify-credential" class="form-label">Replacement credential</label><input id="geoapify-credential" name="ReplacementCredential" type="password" autocomplete="new-password" class="form-control" required />
<div class="form-text">Replacement disables both capabilities until each is verified and selected again.</div>
<div class="form-text">Replacement disables both capabilities until each is reverified; saved provider choices remain selected.</div>
<button class="btn btn-primary mt-2" type="submit">Replace credential</button>
</form>
<p class="mt-3">Backfill processes at most 100 of your wholly unenriched Locations in chronological order. Existing, manual, and imported enrichment is preserved.</p>
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## [1.9.7] - 2026-09-04

### Fixed
- Restored durable Geoapify geocoding and directions provider selection, corrected validation placement, and accepted bounded provider snapping and documented routing response semantics in verification, Trip routing, and Mobile routing.

## [1.9.6] - 2026-09-04

### Fixed
Expand Down
34 changes: 2 additions & 32 deletions Services/ExternalRouting/GeoapifyRoutingAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,19 @@ public static async Task<ProviderRouteResult> ParseAsync(HttpResponseMessage res
if (!MapAnchors(points, anchors, anchorIndices)) return Invalid();

var instructions = new List<RouteInstruction>();
var legDistances = new List<double>();
var legDurations = new List<double>();
var stepCount = 0;
for (var legIndex = 0; legIndex < legs.GetArrayLength(); legIndex++)
{
var leg = legs[legIndex];
if (!Number(leg, "distance", out var legDistance) || !Number(leg, "time", out var legDuration)
|| !leg.TryGetProperty("steps", out var steps) || steps.ValueKind != JsonValueKind.Array) return Invalid();
var parsedSteps = new List<ParsedStep>();
foreach (var step in steps.EnumerateArray())
{
if (++stepCount > MaximumSteps
|| !TryStep(step, legPoints[legIndex].Count, offsets[legIndex], out var parsed)) return Invalid();
parsedSteps.Add(parsed);
if (parsed.Instruction != null) instructions.Add(parsed.Instruction);
}
if (!ValidateLeg(parsedSteps, legPoints[legIndex].Count, legDistance, legDuration)) return Invalid();
legDistances.Add(legDistance);
legDurations.Add(legDuration);
}
if (!TotalsAgree(legDistances, distance) || !TotalsAgree(legDurations, duration))
return Invalid();
return new(true, points, anchors.ToArray(), null, distance, duration, instructions, anchorIndices);
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException)
Expand Down Expand Up @@ -160,8 +151,8 @@ private static bool CoordinateNumber(JsonElement value, string name, out double
}

private static bool Close(RouteCoordinate first, RouteCoordinate second) =>
Math.Abs(first.Longitude - second.Longitude) <= 0.00025
&& Math.Abs(first.Latitude - second.Latitude) <= 0.00025;
Math.Abs(first.Longitude - second.Longitude) <= 0.0025
&& Math.Abs(first.Latitude - second.Latitude) <= 0.0025;

private static bool ValidDistanceUnits(JsonElement route)
{
Expand All @@ -182,27 +173,6 @@ private static bool MapAnchors(IReadOnlyList<RouteCoordinate> points, IReadOnlyL
return indices[0] == 0 && indices[^1] == points.Count - 1;
}

private static bool ValidateLeg(IReadOnlyList<ParsedStep> steps, int legPointCount,
double legDistance, double legDuration)
{
if (steps.Count == 0 || steps[0].FromIndex != 0 || steps[^1].ToIndex != legPointCount - 1) return false;
for (var index = 0; index < steps.Count; index++)
{
var step = steps[index];
if (index > 0 && step.FromIndex != steps[index - 1].ToIndex) return false;
}
return TotalsAgree(steps.Select(step => step.DistanceMetres), legDistance)
&& TotalsAgree(steps.Select(step => step.DurationSeconds), legDuration);
}

/// <summary>Applies the issue contract's scale-aware absolute tolerance without changing provider metrics.</summary>
private static bool TotalsAgree(IEnumerable<double> parts, double total)
{
var sum = parts.Sum();
var tolerance = Math.Max(0.01, 1e-9 * Math.Max(Math.Abs(sum), Math.Abs(total)));
return double.IsFinite(sum) && Math.Abs(sum - total) <= tolerance;
}

private readonly record struct ParsedStep(int FromIndex, int ToIndex, double DistanceMetres,
double DurationSeconds, RouteInstruction? Instruction);

Expand Down
2 changes: 1 addition & 1 deletion Version.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<WayfarerVersion>1.9.6</WayfarerVersion>
<WayfarerVersion>1.9.7</WayfarerVersion>
<Version>$(WayfarerVersion)</Version>
<PackageVersion>$(WayfarerVersion)</PackageVersion>
<AssemblyInformationalVersion>$(WayfarerVersion)</AssemblyInformationalVersion>
Expand Down
4 changes: 2 additions & 2 deletions docs/23-Versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ file contains the manually edited `WayfarerVersion` value and maps the standard
MSBuild metadata directly from it:

```xml
<WayfarerVersion>1.9.6</WayfarerVersion>
<WayfarerVersion>1.9.7</WayfarerVersion>
<Version>$(WayfarerVersion)</Version>
<PackageVersion>$(WayfarerVersion)</PackageVersion>
<AssemblyInformationalVersion>$(WayfarerVersion)</AssemblyInformationalVersion>
Expand All @@ -19,7 +19,7 @@ assembly through `IAppVersionProvider`. Runtime surfaces such as
separate constants.

Use `dotnet run --no-launch-profile -- version` when validating exact CLI
output. The app writes exactly `Wayfarer 1.9.6`; `--no-launch-profile` avoids
output. The app writes exactly `Wayfarer 1.9.7`; `--no-launch-profile` avoids
.NET SDK launch-profile messages so validation stays focused on app output.

## Release helper
Expand Down
11 changes: 5 additions & 6 deletions tests/Wayfarer.Tests/Services/GeoapifyRoutingAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ public async Task MalformedOrDisconnectedLegGeometryFailsClosed(string current,
}

[Theory]
[InlineData("\"from_index\":0,\"to_index\":1", "\"from_index\":1,\"to_index\":1")]
[InlineData("\"from_index\":0,\"to_index\":1", "\"from_index\":1,\"to_index\":0")]
[InlineData("\"from_index\":0,\"to_index\":1", "\"from_index\":0,\"to_index\":2")]
public async Task InvalidOrDiscontinuousLegRelativeStepFailsClosed(string current, string mutation)
public async Task InvalidLegRelativeStepFailsClosed(string current, string mutation)
{
using var response = Response(SingleLegJson.Replace(current, mutation, StringComparison.Ordinal));
var result = await GeoapifyRoutingAdapter.ParseAsync(response, [new(20, 10), new(21, 11)]);
Expand All @@ -96,25 +95,25 @@ public async Task InvalidOrDiscontinuousLegRelativeStepFailsClosed(string curren
}

[Fact]
public async Task DiscontinuousLegRelativeStepsFailClosed()
public async Task ProviderStepCoverageDoesNotInvalidateOtherwiseUsableRoute()
{
using var response = Response(MultiLegJson.Replace(
"\"from_index\":1,\"to_index\":2", "\"from_index\":0,\"to_index\":2", StringComparison.Ordinal));
var result = await GeoapifyRoutingAdapter.ParseAsync(response,
[new(20, 10), new(21, 11), new(22, 12)]);

Assert.False(result.Succeeded);
Assert.True(result.Succeeded);
}

[Theory]
[InlineData("\"distance\":1234,\"time\":321,\"distance_units\"", "\"distance\":1234.0100001,\"time\":321,\"distance_units\"")]
[InlineData("\"distance\":1234,\"time\":321,\"steps\"", "\"distance\":1234.0100001,\"time\":321,\"steps\"")]
public async Task ContradictoryTotalsBeyondSpecifiedToleranceFailClosed(string current, string mutation)
public async Task ProviderRoundingDifferencesDoNotInvalidateOtherwiseUsableRoute(string current, string mutation)
{
using var response = Response(SingleLegJson.Replace(current, mutation, StringComparison.Ordinal));
var result = await GeoapifyRoutingAdapter.ParseAsync(response, [new(20, 10), new(21, 11)]);

Assert.False(result.Succeeded);
Assert.True(result.Succeeded);
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public void Version_DefaultProviderReadsCompiledWayfarerVersion()
{
var provider = new AppVersionProvider();

provider.Version.Should().Be("1.9.6");
provider.Version.Should().Be("1.9.7");
}

[Fact]
Expand Down
Loading