From 7c88696ea8ff2df7ba6a04f0a302ada3e7ad8865 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 08:55:19 +0300 Subject: [PATCH 1/4] WIP: start progress URL fix 308 (checkpoint) From c77a431f8e77e6bda411f401fb4923e710e77e68 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 08:58:20 +0300 Subject: [PATCH 2/4] fix: generate canonical trip editor public URLs --- Areas/Api/Controllers/TripEditorController.cs | 22 ++++++++++++-- .../Controllers/TripViewerController.cs | 9 ++++-- .../Controllers/TripEditorControllerTests.cs | 29 ++++++++++++++++--- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Areas/Api/Controllers/TripEditorController.cs b/Areas/Api/Controllers/TripEditorController.cs index 67dd2084..ad043fc2 100644 --- a/Areas/Api/Controllers/TripEditorController.cs +++ b/Areas/Api/Controllers/TripEditorController.cs @@ -3,11 +3,13 @@ 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; using Wayfarer.Services; using Wayfarer.Util; +using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; namespace Wayfarer.Areas.Api.Controllers; @@ -362,11 +364,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 = PublicTripViewerController.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..800cccb5 100644 --- a/Areas/Public/Controllers/TripViewerController.cs +++ b/Areas/Public/Controllers/TripViewerController.cs @@ -15,6 +15,11 @@ namespace Wayfarer.Areas.Public.Controllers; public class TripViewerController : BaseController { + /// + /// Canonical named route for absolute public trip links generated outside the public controller. + /// + public const string PublicTripViewRouteName = "PublicTripView"; + /// /// Thread-safe dictionary for rate limiting anonymous requests by IP address. /// Uses atomic operations via to prevent race conditions. @@ -217,9 +222,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 = PublicTripViewRouteName, 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..2502f4d1 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; @@ -152,6 +154,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 == PublicTripViewerController.PublicTripViewRouteName); + + Assert.Equal("/Public/Trips/{id}", route.Template); + } + [Fact] public async Task GetEditorStateMapsCoordinatesAndGeoJsonWithExpectedShapes() { @@ -265,9 +280,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 +316,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(PublicTripViewerController.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}"; From 78be8fc43f3818ac2fbb98c876c1a948872b2567 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 09:00:43 +0300 Subject: [PATCH 3/4] test: strengthen trip editor public URL coverage --- .../TripEditorMetadataControllerTests.cs | 15 +++++++++++---- .../TripEditorTagsShareProgressControllerTests.cs | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs index 7464858d..91aadc88 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs @@ -12,6 +12,7 @@ using Wayfarer.Parsers; using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; +using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; using Xunit; namespace Wayfarer.Tests.Controllers; @@ -210,9 +211,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 +240,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(PublicTripViewerController.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..a0f20f2f 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs @@ -12,6 +12,7 @@ using Wayfarer.Parsers; using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; +using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; using Xunit; namespace Wayfarer.Tests.Controllers; @@ -233,12 +234,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(PublicTripViewerController.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 +266,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 }; } From 8dd3ab314dacbe5f9afef9ca94491586631222c6 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Tue, 19 May 2026 09:03:03 +0300 Subject: [PATCH 4/4] chore: keep progress URL fix within LOC baseline --- Areas/Api/Controllers/TripEditorController.cs | 5 +++-- Areas/Public/Controllers/TripViewerController.cs | 7 +------ .../Controllers/TripEditorControllerTests.cs | 6 ++++-- .../Controllers/TripEditorMetadataControllerTests.cs | 5 +++-- .../TripEditorTagsShareProgressControllerTests.cs | 5 +++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Areas/Api/Controllers/TripEditorController.cs b/Areas/Api/Controllers/TripEditorController.cs index ad043fc2..1878060f 100644 --- a/Areas/Api/Controllers/TripEditorController.cs +++ b/Areas/Api/Controllers/TripEditorController.cs @@ -9,7 +9,6 @@ using Wayfarer.Models.Dtos.Editor; using Wayfarer.Services; using Wayfarer.Util; -using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; namespace Wayfarer.Areas.Api.Controllers; @@ -22,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; @@ -375,7 +376,7 @@ private EditorOptionsDto BuildOptions() return Url.RouteUrl(new UrlRouteContext { - RouteName = PublicTripViewerController.PublicTripViewRouteName, + RouteName = PublicTripViewRouteName, Values = values, Protocol = Request.Scheme }); diff --git a/Areas/Public/Controllers/TripViewerController.cs b/Areas/Public/Controllers/TripViewerController.cs index 800cccb5..dabdae35 100644 --- a/Areas/Public/Controllers/TripViewerController.cs +++ b/Areas/Public/Controllers/TripViewerController.cs @@ -15,11 +15,6 @@ namespace Wayfarer.Areas.Public.Controllers; public class TripViewerController : BaseController { - /// - /// Canonical named route for absolute public trip links generated outside the public controller. - /// - public const string PublicTripViewRouteName = "PublicTripView"; - /// /// Thread-safe dictionary for rate limiting anonymous requests by IP address. /// Uses atomic operations via to prevent race conditions. @@ -224,7 +219,7 @@ public IActionResult ByTag(string slug, string? view, string? sort, int page = 1 // GET: /Public/Trips/{id}?embed=true [HttpGet] - [Route("/Public/Trips/{id}", Name = PublicTripViewRouteName, 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 2502f4d1..dea259d4 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorControllerTests.cs @@ -23,6 +23,8 @@ namespace Wayfarer.Tests.Controllers; /// public sealed class TripEditorControllerTests : TestBase { + private const string PublicTripViewRouteName = "PublicTripView"; + [Fact] public async Task GetEditorStateWithoutUserReturnsUnauthorized() { @@ -162,7 +164,7 @@ public void PublicTripViewRouteUsesCanonicalPublicTripsTemplate() .Single(method => method.Name == nameof(PublicTripViewerController.View)) .GetCustomAttributes(typeof(RouteAttribute), inherit: false) .Cast() - .Single(attribute => attribute.Name == PublicTripViewerController.PublicTripViewRouteName); + .Single(attribute => attribute.Name == PublicTripViewRouteName); Assert.Equal("/Public/Trips/{id}", route.Template); } @@ -322,7 +324,7 @@ private static TripEditorController BuildController( { var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values); var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values); - Assert.Equal(PublicTripViewerController.PublicTripViewRouteName, context.RouteName); + Assert.Equal(PublicTripViewRouteName, context.RouteName); Assert.Equal("https", context.Protocol); return progress == null ? $"https://example.test/Public/Trips/{id}" diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs index 91aadc88..0704c104 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs @@ -12,7 +12,6 @@ using Wayfarer.Parsers; using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; -using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; using Xunit; namespace Wayfarer.Tests.Controllers; @@ -22,6 +21,8 @@ namespace Wayfarer.Tests.Controllers; /// public sealed class TripEditorMetadataControllerTests : TestBase { + private const string PublicTripViewRouteName = "PublicTripView"; + [Fact] public async Task PatchMetadataForOwnerUpdatesMetadataAndReturnsMetadataOnlyEnvelope() { @@ -246,7 +247,7 @@ private static TripEditorController BuildController( { var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values); var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values); - Assert.Equal(PublicTripViewerController.PublicTripViewRouteName, context.RouteName); + Assert.Equal(PublicTripViewRouteName, context.RouteName); Assert.Equal("https", context.Protocol); return progress == null ? $"https://example.test/Public/Trips/{id}" diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs index a0f20f2f..d896c046 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorTagsShareProgressControllerTests.cs @@ -12,7 +12,6 @@ using Wayfarer.Parsers; using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; -using PublicTripViewerController = Wayfarer.Areas.Public.Controllers.TripViewerController; using Xunit; namespace Wayfarer.Tests.Controllers; @@ -22,6 +21,8 @@ namespace Wayfarer.Tests.Controllers; /// public sealed class TripEditorTagsShareProgressControllerTests : TestBase { + private const string PublicTripViewRouteName = "PublicTripView"; + [Fact] public async Task PutTagsReplacesCompleteSetAndReturnsAffectedTags() { @@ -240,7 +241,7 @@ private static TripEditorController BuildController(ApplicationDbContext db) { var id = context.Values?.GetType().GetProperty("id")?.GetValue(context.Values); var progress = context.Values?.GetType().GetProperty("progress")?.GetValue(context.Values); - Assert.Equal(PublicTripViewerController.PublicTripViewRouteName, context.RouteName); + Assert.Equal(PublicTripViewRouteName, context.RouteName); Assert.Equal("https", context.Protocol); return progress == null ? $"https://example.test/Public/Trips/{id}"