using Api.SeaHavenIndustries.Controllers; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Xunit; using Xunit.Abstractions; namespace Api.SeaHavenIndustries.Tests; /// /// Framework-backed route contract tests that prove the public endpoint set exposed by the /// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the /// original godfile WorkOrderController into cohesive controllers, and that no verb+route /// combination is registered more than once by different actions. /// /// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the /// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder) /// and the two shared controller-level base routes. /// public class WorkOrderRouteContractTests { private readonly ITestOutputHelper _output; private static readonly HashSet WorkOrderFamilyControllerTypes = new() { typeof(WorkOrderController), typeof(WorkOrderBoardController), typeof(WorkOrderDetailController), typeof(WorkOrderCompletionController), typeof(WorkOrderMediaController), typeof(WorkOrderDispatchController), }; /// /// Baseline public endpoint set (verb + action-relative route) that the original single /// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122). /// Every action is reachable under both api/WorkOrder and api/workorders; that base-route /// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes /// come from 51 actions (Editworkorder binds two routes). /// private static readonly HashSet ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal) { "DELETE DeleteWorkorder", "DELETE completion-templates/{id:int}", "DELETE {id:int}/media/{mediaId:int}", "GET GetComments", "GET GetCommentsByWorkorderId", "GET GetDispatch/{id}", "GET GetDispatches/{workOrderId}", "GET GetFilteredWorkorder", "GET GetFilteredWorkorder2", "GET GetWorkOrderList", "GET GetWorkorderById", "GET Getworkorders", "GET GetworkordersDD", "GET board", "GET board/search", "GET completion-templates", "GET completion-templates/{id:int}", "GET lookups/dispatchers", "GET {id:int}", "GET {id:int}/audit", "GET {id:int}/comments", "GET {id:int}/detail", "GET {id:int}/uplifts", "GET {id:int}/media", "GET {id:int}/media/{mediaId:int}/content", "PATCH {id:int}/board", "PATCH {id:int}/comments/{commentId:int}", "PATCH {id:int}/media/{mediaId:int}", "PATCH {id:int}/poc", "POST AddChecklistItem", "POST AddComment", "POST AddCommentJson", "POST AddDispatchComment", "POST AddDispatchSignoff", "POST AddWorkorder", "POST ChangeAssignment", "POST ChangeStatus", "POST DispatchToVendor", "POST EditWorkorder", "POST UpdateChecklistItem", "POST UpdateDispatch", "POST VerifyDispatch", "POST board", "POST completion-templates", "POST {id:int}/cancel", "POST {id:int}/comments", "POST {id:int}/completion-doc", "POST {id:int}/media", "POST {id:int}/uplifts", "POST {id:int}/uplifts/{upliftId:int}/cancel", "POST {id:int}/uplifts/{upliftId:int}/revoke", "PUT completion-templates/{id:int}", "PUT {id:int}", }; public WorkOrderRouteContractTests(ITestOutputHelper output) { _output = output; } private static IReadOnlyList BuildActionDescriptors() { var services = new ServiceCollection(); services.AddLogging(); services.AddMvcCore() .AddApplicationPart(typeof(WorkOrderController).Assembly); using var provider = services.BuildServiceProvider(); var actionProvider = provider.GetRequiredService(); return actionProvider.ActionDescriptors.Items; } private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor( Func predicate) { var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>(); foreach (var action in BuildActionDescriptors().Where(predicate)) { var cad = action as ControllerActionDescriptor; var template = cad?.AttributeRouteInfo?.Template?.Trim('/'); if (string.IsNullOrEmpty(template)) continue; var methods = action.ActionConstraints .OfType() .SelectMany(c => c.HttpMethods) .Distinct(StringComparer.OrdinalIgnoreCase); foreach (var method in methods) tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName)); } return tuples; } private static string Normalize(string fullTemplate) { foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" }) { if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return fullTemplate[prefix.Length..]; } foreach (var exact in new[] { "api/WorkOrder", "api/workorders" }) { if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase)) return string.Empty; } return fullTemplate; } [Fact] public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set() { var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") .ToHashSet(StringComparer.Ordinal); Dump(actual); var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList(); var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList(); missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable"); unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints"); actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count, "the distinct action-relative endpoint count must match the baseline"); actual.Should().NotContain(endpoint => endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); } [Fact] public void WorkOrder_Family_Does_Not_Expose_Documents_Route() { ExpectedWorkOrderEndpoints.Should().Contain("GET {id:int}/media/{mediaId:int}/content"); ExpectedWorkOrderEndpoints.Should().NotContain(endpoint => endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) .Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}") .ToHashSet(StringComparer.Ordinal); actual.Should().NotContain(endpoint => endpoint.Contains("documents", StringComparison.OrdinalIgnoreCase)); } [Fact] public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() { // Duplicate detection is done at the FULL route-template level. Each action legitimately // resolves under both base routes (api/WorkOrder and api/workorders); those are distinct // full templates. A true collision is the SAME full template produced by two actions, // which would cause an AmbiguousMatchException at request time. var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad && WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo)) .GroupBy(t => (t.Verb, t.FullTemplate)) .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") .ToList(); collisions.Should().BeEmpty( "no two actions in the WorkOrder family may register the same verb+full-route"); } [Fact] public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions() { var collisions = FullRoutesFor(_ => true) .Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase) || t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase)) .GroupBy(t => (t.Verb, t.FullTemplate)) .Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1) .Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}") .ToList(); collisions.Should().BeEmpty( "no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route"); } private void Dump(IEnumerable endpoints) { _output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):"); foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal)) _output.WriteLine(" " + endpoint); } }