Compare commits

...

4 commits

Author SHA1 Message Date
Arthur Bassi
dd2a805c24 fix(work-orders): return 400 for invalid advanced-search custom date range
Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract.
2026-07-10 10:56:18 -03:00
Arthur Bassi
0ddc43210a Merge remote-tracking branch 'origin/feat/work-orders-phase-3' into feat/work-orders-phase-4 2026-07-10 10:54:15 -03:00
Arthur Bassi
9dc17d4255 fix(work-orders): persist WO identity before staging create audit and locks 2026-07-10 10:49:57 -03:00
Arthur Bassi
83272e4957 Revert "fix(work-orders): address PR #14 board PATCH review feedback"
This reverts commit eb64c2f55c.
2026-07-09 09:59:41 -03:00
11 changed files with 265 additions and 110 deletions

View file

@ -0,0 +1,101 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderControllerSearchTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
}
private static WorkOrderController NewController(
ApplicationDbContext ctx,
IWorkOrderAdvancedSearchService advancedSearch)
{
var controller = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
"test-user")
},
"Test"))
}
};
return controller;
}
[Fact]
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{
using var ctx = NewContext();
var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(ctx, advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom
});
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("dateFrom");
}
}

View file

@ -120,9 +120,16 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("board/search")]
public async Task<IActionResult> SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId);
return Ok(result);
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("lookups/dispatchers")]
@ -148,14 +155,6 @@ namespace Api.SeaHavenIndustries.Controllers
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto

View file

@ -28,17 +28,17 @@ namespace SeaHaven.Services.Implementation
=> LogAsync(workOrderId, AuditActionType.SyncRejected, AuditEventType.Sync,
AuditActorType.Sync, fieldName, oldValue, newValue);
public Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAuditAsync(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null)
=> StageAudit(workOrderId, AuditActionType.FieldChanged, fieldName, oldValue, newValue, actorId, dispatchId);
public Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId)
=> StageAudit(workOrderId, AuditActionType.StatusChanged, "LifecycleStatus", oldStatus, newStatus, actorId);
public Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAuditAsync(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId)
=> StageAudit(workOrderId, AuditActionType.AssignmentChanged, "AssignTo", oldValue, newValue, actorId);
public Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber)
=> StageAuditAsync(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public void StageCreated(int workOrderId, string? actorId, string? woNumber)
=> StageAudit(workOrderId, AuditActionType.Create, "WorkOrder", null, woNumber, actorId);
public async Task LogAsync(
int workOrderId,
@ -65,7 +65,7 @@ namespace SeaHaven.Services.Implementation
await _context.SaveChangesAsync();
}
private async Task StageAuditAsync(
private void StageAudit(
int workOrderId,
AuditActionType action,
string fieldName,
@ -76,13 +76,7 @@ namespace SeaHaven.Services.Implementation
{
AddAuditEntry(workOrderId, action, AuditEventType.Manual, AuditActorType.Dispatcher,
fieldName, oldValue, newValue, actorId, dispatchId);
if (action == AuditActionType.FieldChanged ||
action == AuditActionType.StatusChanged ||
action == AuditActionType.AssignmentChanged)
{
await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId);
}
StageFieldLock(workOrderId, fieldName, actorId);
}
private void AddAuditEntry(
@ -113,5 +107,21 @@ namespace SeaHaven.Services.Implementation
});
}
private void StageFieldLock(int workOrderId, string fieldName, string? userId)
{
var alreadyTracked = _context.WorkOrderFieldLocks.Local
.Any(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName);
if (alreadyTracked)
return;
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
{
WorkOrderId = workOrderId,
FieldName = fieldName,
LockedAt = DateTime.UtcNow,
LockedByUserId = userId
});
}
}
}

View file

@ -47,7 +47,7 @@ namespace SeaHaven.Services.Implementation
if (workOrder.LegacyStatus == null && workOrder.Status != null)
workOrder.LegacyStatus = workOrder.Status;
await _auditService.StageStatusChangedAsync(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
_auditService.StageStatusChanged(workOrderId, oldStatus, LifecycleStatus.Canceled.ToString(), actorId);
await _context.SaveChangesAsync();
var row = await _boardService.GetBoardRowAsync(workOrderId);

View file

@ -123,19 +123,17 @@ namespace SeaHaven.Services.Implementation
}
_context.workOrders.Add(workOrder);
await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber);
await StageChangesAsync(workOrder.Id, changes, actorId, dispatch);
await _context.SaveChangesAsync();
if (dispatch != null && workOrder.PrimaryDispatchId == null)
{
workOrder.PrimaryDispatchId = dispatch.Id;
dispatch.WorkOrderId = workOrder.Id;
await _context.SaveChangesAsync();
}
_auditService.StageCreated(workOrder.Id, actorId, woNumber);
StageChanges(workOrder.Id, changes, actorId, dispatch);
if (request.PocContactId.HasValue)
{
_context.WorkOrderContacts.Add(new WorkOrderContacts
@ -144,9 +142,10 @@ namespace SeaHaven.Services.Implementation
ContactId = request.PocContactId,
Notes = string.IsNullOrWhiteSpace(request.PocNotes) ? null : request.PocNotes.Trim()
});
await _context.SaveChangesAsync();
}
await _context.SaveChangesAsync();
var row = await _boardService.GetBoardRowAsync(workOrder.Id);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
}
@ -183,7 +182,7 @@ namespace SeaHaven.Services.Implementation
return normalized;
}
private async Task StageChangesAsync(
private void StageChanges(
int workOrderId,
IEnumerable<WorkOrderBoardFieldMutations.BoardFieldChange> changes,
string? actorId,
@ -195,13 +194,13 @@ namespace SeaHaven.Services.Implementation
switch (change.Action)
{
case AuditActionType.StatusChanged:
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue ?? "", change.NewValue, actorId, dispatchId);
break;
}
}

View file

@ -106,17 +106,16 @@ namespace SeaHaven.Services.Implementation
foreach (var change in changes)
{
var dispatchId = change.DispatchId ?? (dispatch?.Id > 0 ? dispatch.Id : null);
switch (change.Action)
{
case AuditActionType.StatusChanged:
await _auditService.StageStatusChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
_auditService.StageStatusChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
case AuditActionType.AssignmentChanged:
await _auditService.StageAssignmentChangedAsync(workOrderId, change.OldValue, change.NewValue, actorId);
_auditService.StageAssignmentChanged(workOrderId, change.OldValue, change.NewValue, actorId);
break;
default:
await _auditService.StageFieldChangedAsync(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, dispatchId);
_auditService.StageFieldChanged(workOrderId, change.FieldName, change.OldValue, change.NewValue, actorId, change.DispatchId);
break;
}
}
@ -196,7 +195,8 @@ namespace SeaHaven.Services.Implementation
CreatedDate = DateTime.UtcNow
};
_context.Dispatches.Add(created);
workOrder.PrimaryDispatch = created;
await _context.SaveChangesAsync();
workOrder.PrimaryDispatchId = created.Id;
return created;
}
@ -392,10 +392,10 @@ namespace SeaHaven.Services.Implementation
var old = dispatch.VendorId.ToString();
if (dispatch.VendorId == vendorId.Value)
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
return FieldChange.Unchanged(auditField, dispatch.Id);
dispatch.VendorId = vendorId.Value;
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), ResolveDispatchIdForAudit(dispatch));
return FieldChange.ForField(auditField, old, vendorId.Value.ToString(), dispatch.Id);
}
private static FieldChange ApplyApptDate(Dispatch dispatch, string? value, string auditField)
@ -411,14 +411,12 @@ namespace SeaHaven.Services.Implementation
var old = FormatDate(dispatch.ScheduledDate);
var newVal = FormatDate(parsed);
if (old == newVal)
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
return FieldChange.Unchanged(auditField, dispatch.Id);
dispatch.ScheduledDate = parsed;
return FieldChange.ForField(auditField, old, newVal, ResolveDispatchIdForAudit(dispatch));
return FieldChange.ForField(auditField, old, newVal, dispatch.Id);
}
// apptTime is stored on WorkOrder (ScheduledStart/End) but requires a primary dispatch
// for concurrency (dispatchVersion) and audit context; apptDate lives on Dispatch.ScheduledDate.
private static FieldChange ApplyApptTime(WorkOrder workOrder, Dispatch dispatch, string? value, string auditField)
{
if (!WorkOrderBoardApptTimeParser.TryParse(value, out var start, out var end, out var error))
@ -437,9 +435,9 @@ namespace SeaHaven.Services.Implementation
var newCombined = FormatApptRange(newStart, newEnd);
if (oldCombined == newCombined)
return FieldChange.Unchanged(auditField, ResolveDispatchIdForAudit(dispatch));
return FieldChange.Unchanged(auditField, dispatch.Id);
return FieldChange.ForField(auditField, oldCombined, newCombined, ResolveDispatchIdForAudit(dispatch));
return FieldChange.ForField(auditField, oldCombined, newCombined, dispatch.Id);
}
private static FieldChange ApplyDocStatus(WorkOrder workOrder, string? value, string auditField)
@ -502,9 +500,6 @@ namespace SeaHaven.Services.Implementation
return vendorId;
}
private static int? ResolveDispatchIdForAudit(Dispatch dispatch)
=> dispatch.Id > 0 ? dispatch.Id : null;
private static string? FormatDate(DateTime? value) => value?.ToString("yyyy-MM-dd");
private static string? FormatDateTime(DateTime? value) => value?.ToString("O");
private static string? FormatApptRange(string? start, string? end)

View file

@ -20,9 +20,9 @@ namespace SeaHaven.Services.Interfaces
Task LogAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task LogSyncRejectedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue);
Task StageFieldChangedAsync(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
Task StageStatusChangedAsync(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
Task StageAssignmentChangedAsync(int workOrderId, string? oldValue, string? newValue, string? actorId);
Task StageCreatedAsync(int workOrderId, string? actorId, string? woNumber);
void StageFieldChanged(int workOrderId, string fieldName, string? oldValue, string? newValue, string? actorId, int? dispatchId = null);
void StageStatusChanged(int workOrderId, string? oldStatus, string? newStatus, string? actorId);
void StageAssignmentChanged(int workOrderId, string? oldValue, string? newValue, string? actorId);
void StageCreated(int workOrderId, string? actorId, string? woNumber);
}
}

View file

@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />

View file

@ -0,0 +1,97 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
namespace SeaHavenIndustries.Tests;
public class WorkOrderBoardCreateRelationalTests
{
[Fact]
public async Task Create_AuditAndFieldLocks_UseGeneratedWorkOrderId()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new SqliteBoardTestDbContext(options);
await context.Database.EnsureCreatedAsync();
const string actorId = "actor-1";
context.Users.Add(new ApplicationUser
{
Id = actorId,
UserName = "actor-1",
NormalizedUserName = "ACTOR-1",
Email = "actor@test.local",
NormalizedEmail = "ACTOR@TEST.LOCAL"
});
await context.SaveChangesAsync();
var boardData = new WorkOrderBoardDataService(context);
var boardService = new WorkOrderBoardService(boardData);
var fieldLocks = new WorkOrderFieldLockService(context);
var audit = new WorkOrderAuditService(context, fieldLocks);
var service = new WorkOrderBoardCreateService(
context,
boardData,
boardService,
audit,
new WorkOrderBoardCreateValidation());
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
Description = "Relational create"
}, actorId);
Assert.True(result.Id > 0);
var logs = await context.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
var locks = await context.WorkOrderFieldLocks.AsNoTracking().ToListAsync();
Assert.NotEmpty(logs);
Assert.NotEmpty(locks);
Assert.Contains(logs, l => l.Action == "Create");
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId));
Assert.DoesNotContain(logs, l => l.WorkOrderId == 0);
Assert.DoesNotContain(locks, l => l.WorkOrderId == 0);
}
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
{
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}

View file

@ -149,7 +149,7 @@ public class WorkOrderBoardCreateServiceTests
{
var (context, service) = CreateSut();
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
@ -160,6 +160,7 @@ public class WorkOrderBoardCreateServiceTests
Assert.Contains(locks, l => l.FieldName == "SiteCode");
Assert.Contains(locks, l => l.FieldName == "WorkOrderType");
Assert.Contains(locks, l => l.FieldName == "Description");
Assert.All(locks, l => Assert.Equal(result.Id, l.WorkOrderId));
}
[Fact]
@ -167,7 +168,7 @@ public class WorkOrderBoardCreateServiceTests
{
var (context, service) = CreateSut();
await service.CreateAsync(new WorkOrderBoardCreateRequestDto
var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5"
@ -176,5 +177,7 @@ public class WorkOrderBoardCreateServiceTests
var logs = await context.WorkOrderAuditLogs.ToListAsync();
Assert.Contains(logs, l => l.Action == "Create");
Assert.Contains(logs, l => l.Action == "FieldChanged" && l.FieldName == "SiteCode");
Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId));
Assert.True(result.Id > 0);
}
}

View file

@ -180,56 +180,6 @@ public class WorkOrderBoardUpdateServiceTests
Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode"));
}
[Fact]
public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock()
{
var (context, service) = CreateSut();
var wo = new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
SiteCode = "OLD",
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
};
context.workOrders.Add(wo);
await context.SaveChangesAsync();
await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = ToVersion(wo)
}, "actor-1");
var updated = await context.workOrders.FindAsync(1);
var secondResult = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK6",
WorkOrderVersion = ToVersion(updated!)
}, "actor-1");
Assert.Equal("BK6", secondResult.SiteCode);
Assert.Single(await context.WorkOrderFieldLocks.Where(l => l.FieldName == "SiteCode").ToListAsync());
Assert.Equal(2, await context.WorkOrderAuditLogs.Where(a => a.FieldName == "SiteCode").CountAsync());
}
[Fact]
public async Task PatchField_NotFound_ThrowsNotFoundCode()
{
var (_, service) = CreateSut();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.PatchFieldAsync(999, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.SiteCode,
Value = "BK5",
WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 })
}, "actor-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task PatchField_NormalizesWoNumber()
{