mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
231 lines
9.8 KiB
C#
231 lines
9.8 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. 52 routes
|
|
/// come from 51 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 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}",
|
|
"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<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);
|
|
}
|
|
}
|