diff --git a/Areas/Api/Controllers/TripEditorController.cs b/Areas/Api/Controllers/TripEditorController.cs
index 67dd2084..1878060f 100644
--- a/Areas/Api/Controllers/TripEditorController.cs
+++ b/Areas/Api/Controllers/TripEditorController.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.EntityFrameworkCore;
using Wayfarer.Models;
using Wayfarer.Models.Dtos.Editor;
@@ -20,6 +21,8 @@ namespace Wayfarer.Areas.Api.Controllers;
[Route("api/trips/{tripId:guid}/editor")]
public sealed partial class TripEditorController : ControllerBase
{
+ private const string PublicTripViewRouteName = "PublicTripView";
+
private readonly ApplicationDbContext _dbContext;
private readonly IWebHostEnvironment _environment;
private readonly IIconColorProvider _iconColorProvider;
@@ -362,11 +365,25 @@ private EditorOptionsDto BuildOptions()
new EditorLimitsDto(6, 1));
}
- private string? GeneratePublicTripUrl(Guid tripId) =>
- Url.Action("View", "TripViewer", new { area = "Public", id = tripId }, Request.Scheme);
+ ///
+ /// Generates absolute public trip links through the named attribute route to avoid conventional area fallback URLs.
+ ///
+ private string? GeneratePublicTripUrl(Guid tripId, int? progress = null)
+ {
+ object values = progress.HasValue
+ ? new { id = tripId, progress = progress.Value }
+ : new { id = tripId };
+
+ return Url.RouteUrl(new UrlRouteContext
+ {
+ RouteName = PublicTripViewRouteName,
+ Values = values,
+ Protocol = Request.Scheme
+ });
+ }
private string? GenerateProgressPublicTripUrl(Guid tripId) =>
- Url.Action("View", "TripViewer", new { area = "Public", id = tripId, progress = 1 }, Request.Scheme);
+ GeneratePublicTripUrl(tripId, progress: 1);
private IActionResult? RequireEditorUser(out string? userId)
{
diff --git a/Areas/Public/Controllers/TripViewerController.cs b/Areas/Public/Controllers/TripViewerController.cs
index 80b42618..dabdae35 100644
--- a/Areas/Public/Controllers/TripViewerController.cs
+++ b/Areas/Public/Controllers/TripViewerController.cs
@@ -217,9 +217,9 @@ public IActionResult ByTag(string slug, string? view, string? sort, int page = 1
return RedirectToRoute("PublicTripsIndex", new { tags = slug, view, sort, page });
}
- // GET: /Public/Trips/View/{id}?embed=true
+ // GET: /Public/Trips/{id}?embed=true
[HttpGet]
- [Route("/Public/Trips/{id}", Order = 2)]
+ [Route("/Public/Trips/{id}", Name = "PublicTripView", Order = 2)]
public async Task View(Guid id, bool embed = false)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs
index a48db0c1..dea259d4 100644
--- a/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs
+++ b/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,7 @@
using Wayfarer.Parsers;
using Wayfarer.Services;
using Wayfarer.Tests.Infrastructure;
+using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController;
using Xunit;
namespace Wayfarer.Tests.Controllers;
@@ -21,6 +23,8 @@ namespace Wayfarer.Tests.Controllers;
///
public sealed class TripEditorControllerTests : TestBase
{
+ private const string PublicTripViewRouteName = "PublicTripView";
+
[Fact]
public async Task GetEditorStateWithoutUserReturnsUnauthorized()
{
@@ -152,6 +156,19 @@ public async Task GetEditorStateForPublicTripWithProgressEnabledReturnsBothPubli
Assert.Equal("https://example.test/Public/Trips/" + trip.Id + "?progress=1", metadata.ProgressPublicUrl);
}
+ [Fact]
+ public void PublicTripViewRouteUsesCanonicalPublicTripsTemplate()
+ {
+ var route = typeof(PublicTripViewerController)
+ .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
+ .Single(method => method.Name == nameof(PublicTripViewerController.View))
+ .GetCustomAttributes(typeof(RouteAttribute), inherit: false)
+ .Cast()
+ .Single(attribute => attribute.Name == PublicTripViewRouteName);
+
+ Assert.Equal("/Public/Trips/{id}", route.Template);
+ }
+
[Fact]
public async Task GetEditorStateMapsCoordinatesAndGeoJsonWithExpectedShapes()
{
@@ -265,9 +282,13 @@ public async Task GetEditorStateWithMissingAreaGeometryReturnsProblemDetails()
private static void ConfigureControllerWithUserRole(ControllerBase controller, string userId, string role = "User")
{
+ var httpContext = BuildHttpContextWithUser(userId, role);
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("example.test");
+
controller.ControllerContext = new ControllerContext
{
- HttpContext = BuildHttpContextWithUser(userId, role)
+ HttpContext = httpContext
};
}
@@ -297,12 +318,14 @@ private static TripEditorController BuildController(
new TripEditorSegmentMutationService(db),
Mock.Of>());
- var url = new Mock();
- url.Setup(u => u.Action(It.IsAny()))
- .Returns((UrlActionContext context) =>
+ var url = new Mock(MockBehavior.Strict);
+ url.Setup(u => u.RouteUrl(It.IsAny()))
+ .Returns((UrlRouteContext context) =>
{
var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values);
var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values);
+ Assert.Equal(PublicTripViewRouteName, context.RouteName);
+ Assert.Equal("https", context.Protocol);
return progress == null
? $"https://example.test/Public/Trips/{id}"
: $"https://example.test/Public/Trips/{id}?progress={progress}";
diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
index 7464858d..0704c104 100644
--- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
+++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
@@ -21,6 +21,8 @@ namespace Wayfarer.Tests.Controllers;
///
public sealed class TripEditorMetadataControllerTests : TestBase
{
+ private const string PublicTripViewRouteName = "PublicTripView";
+
[Fact]
public async Task PatchMetadataForOwnerUpdatesMetadataAndReturnsMetadataOnlyEnvelope()
{
@@ -210,9 +212,13 @@ await PatchMetadata(
private static void ConfigureControllerWithUserRole(ControllerBase controller, string userId, string role = "User")
{
+ var httpContext = BuildHttpContextWithUser(userId, role);
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("example.test");
+
controller.ControllerContext = new ControllerContext
{
- HttpContext = BuildHttpContextWithUser(userId, role)
+ HttpContext = httpContext
};
}
@@ -235,12 +241,14 @@ private static TripEditorController BuildController(
new TripEditorSegmentMutationService(db),
Mock.Of>());
- var url = new Mock();
- url.Setup(u => u.Action(It.IsAny()))
- .Returns((UrlActionContext context) =>
+ var url = new Mock(MockBehavior.Strict);
+ url.Setup(u => u.RouteUrl(It.IsAny()))
+ .Returns((UrlRouteContext context) =>
{
var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values);
var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values);
+ Assert.Equal(PublicTripViewRouteName, context.RouteName);
+ Assert.Equal("https", context.Protocol);
return progress == null
? $"https://example.test/Public/Trips/{id}"
: $"https://example.test/Public/Trips/{id}?progress={progress}";
diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs
index 901ae841..d896c046 100644
--- a/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs
+++ b/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs
@@ -21,6 +21,8 @@ namespace Wayfarer.Tests.Controllers;
///
public sealed class TripEditorTagsShareProgressControllerTests : TestBase
{
+ private const string PublicTripViewRouteName = "PublicTripView";
+
[Fact]
public async Task PutTagsReplacesCompleteSetAndReturnsAffectedTags()
{
@@ -233,12 +235,14 @@ private static TripEditorController BuildController(ApplicationDbContext db)
new TripEditorSegmentMutationService(db),
Mock.Of>());
- var url = new Mock();
- url.Setup(u => u.Action(It.IsAny()))
- .Returns((UrlActionContext context) =>
+ var url = new Mock(MockBehavior.Strict);
+ url.Setup(u => u.RouteUrl(It.IsAny()))
+ .Returns((UrlRouteContext context) =>
{
var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values);
var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values);
+ Assert.Equal(PublicTripViewRouteName, context.RouteName);
+ Assert.Equal("https", context.Protocol);
return progress == null
? $"https://example.test/Public/Trips/{id}"
: $"https://example.test/Public/Trips/{id}?progress={progress}";
@@ -263,9 +267,13 @@ private static async Task SendJson(
private static void ConfigureControllerWithUserRole(ControllerBase controller, string userId, string role = "User")
{
+ var httpContext = BuildHttpContextWithUser(userId, role);
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("example.test");
+
controller.ControllerContext = new ControllerContext
{
- HttpContext = BuildHttpContextWithUser(userId, role)
+ HttpContext = httpContext
};
}