using System.Reflection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Xunit; namespace RadicadeServer.Tests; /// /// Two endpoints that claim the same method + path make ASP.NET throw /// AmbiguousMatchException (HTTP 500) for EVERY request to that path, before any /// authorization or handler code runs. That is how a club "set minimum level" or "remove member" /// click became an unhandled exception in the server log instead of an answer. /// /// The route table is therefore asserted directly: a controller-level test cannot see two /// endpoints fighting over one URL, because it calls the handler it wants by name. /// /// The classic way this happens here is an action carrying both the class-relative template /// ("{clubId}/minlevel" under [Route("clubs/club")]) and an absolute /// "~/clubs/club/{clubId}/minlevel" copy of it: the same path, registered twice. /// public class RouteAmbiguityTests { /// /// {id} and {id:long} both match a numeric segment with the same route /// precedence, so ASP.NET reports ambiguity for that pair as well - compare on the /// constraint-free form to catch both kinds. /// private static string Unconstrained(string template) { var result = new System.Text.StringBuilder(template.Length); var index = 0; while (true) { var start = template.IndexOf('{', index); if (start < 0) { result.Append(template, index, template.Length - index); return result.ToString(); } var end = template.IndexOf('}', start); if (end < 0) { result.Append(template, index, template.Length - index); return result.ToString(); } var name = template.Substring(start + 1, end - start - 1).TrimStart('*'); var cut = name.IndexOfAny(new[] { ':', '(' }); if (cut >= 0) name = name.Substring(0, cut); result.Append(template, index, start - index).Append('{').Append(name).Append('}'); index = end + 1; } } private static IEnumerable<(string Verb, string Template, string Owner)> Routes() { var assembly = typeof(RadicadeServer.Clubs.ClubsAPI).Assembly; foreach (var type in assembly.GetTypes()) { if (!typeof(ControllerBase).IsAssignableFrom(type) || type.IsAbstract) continue; var classRoutes = type.GetCustomAttributes(inherit: false) .Select(attribute => (attribute.Template ?? string.Empty).Trim('/')) .ToArray(); foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { foreach (var attribute in method.GetCustomAttributes(inherit: false)) { var template = attribute.Template ?? string.Empty; if (template.StartsWith("~/", StringComparison.Ordinal)) { foreach (var verb in attribute.HttpMethods) yield return (verb, template.Substring(2), $"{type.Name}.{method.Name}"); continue; } var prefixes = classRoutes.Length == 0 ? new[] { string.Empty } : classRoutes; foreach (var prefix in prefixes) { var combined = prefix.Length == 0 ? template : $"{prefix}/{template.TrimStart('/')}"; foreach (var verb in attribute.HttpMethods) yield return (verb, combined, $"{type.Name}.{method.Name}"); } } } } } [Fact] public void NoTwoEndpointsClaimTheSameMethodAndPath() { var duplicates = Routes() .GroupBy(route => (route.Verb, Path: Unconstrained(route.Template))) .Where(group => group.Select(route => (route.Owner, route.Template)).Distinct().Count() > 1) .Select(group => $"{group.Key.Verb} /{group.Key.Path} is claimed by " + string.Join(" and ", group.Select(route => $"{route.Owner} [{route.Template}]").Distinct())) .ToArray(); Assert.Empty(duplicates); } }