mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.
- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
+ WorkOrderPocDataService: persists the override, stages FieldChanged audit
entries (which also write field locks so sync never overwrites a manual POC),
and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
232 lines
9.8 KiB
C#
232 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}",
|
|
"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<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);
|
|
}
|
|
}
|