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