shoc-backend/Api.SeaHavenIndustries.Tests/WorkOrderRouteContractTests.cs
Arthur Bassi 31dd2d5dcd merge(main): keep uplift evidence on the current work-order routes
The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
2026-09-28 15:56:45 -03:00

235 lines
10 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class WorkOrderRouteContractTests
{
private readonly ITestOutputHelper _output;
private static readonly HashSet<Type> WorkOrderFamilyControllerTypes = new()
{
typeof(WorkOrderController),
typeof(WorkOrderBoardController),
typeof(WorkOrderDetailController),
typeof(WorkOrderCompletionController),
typeof(WorkOrderMediaController),
typeof(WorkOrderDispatchController),
};
/// <summary>
/// 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. 56 routes
/// come from 55 actions (Editworkorder binds two routes).
/// </summary>
private static readonly HashSet<string> 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 completion-templates/{id:int}/linked-work-orders",
"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}/uplift-evidence/{documentId:int}",
"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}/uplift-evidence",
"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<ActionDescriptor> BuildActionDescriptors()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items;
}
private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor(
Func<ActionDescriptor, bool> 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<HttpMethodActionConstraint>()
.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<string> endpoints)
{
_output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}