Merge remote-tracking branch 'origin/main' into lane/sh-387

This commit is contained in:
Alexandre Brandizzi 2026-09-24 22:25:54 -03:00
commit 24ad21d7f2
15 changed files with 5063 additions and 53 deletions

View file

@ -319,7 +319,7 @@ public sealed class TeamMemberServiceTests
[Fact]
public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
{
var service = NewService(out var userManager, out var roleManager, out _, out _, out _, out _);
var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _);
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
@ -332,10 +332,57 @@ public sealed class TeamMemberServiceTests
result.Success.Should().BeFalse();
result.Error.Should().Be("Email is already in use.");
userData.Verify(
data => data.ExecuteTransactionalAsync(It.IsAny<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation()
{
var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _);
ApplicationUser? created = null;
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "mid-fail-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
.ReturnsAsync(IdentityResult.Success);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas
.Setup(data => data.ReplaceAsync(
"mid-fail-user",
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
overrides
.Setup(data => data.SetOverridesAsync(
"mid-fail-user",
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
failure.Should().BeOfType<InvalidOperationException>()
.Which.Message.Should().Be("injected mid-sequence failure");
created.Should().NotBeNull();
areas.Verify(
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static TeamMemberService NewService(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
@ -350,6 +397,11 @@ public sealed class TeamMemberServiceTests
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
permissions = new Mock<ITeamPermissionService>();
userData
.Setup(data => data.ExecuteTransactionalAsync(
It.IsAny<Func<CancellationToken, Task>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
return new TeamMemberService(
userManager.Object,
roleManager.Object,

View file

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <summary>
/// SH-393: board-created work orders with a vendor stored their primary dispatch with
/// no WorkOrderId and no DispatchWorkOrders link, so every owned-or-linked read (uplift
/// list, allowance, approval queue WO number, dispatch patches) missed it. Data-only
/// heal: assign the dispatch to the single work order that names it as primary. Rows
/// already owned or linked, and dispatches named primary by more than one work order,
/// are left untouched, so re-running is a no-op.
/// </summary>
public partial class SH393_BackfillBoardCreatedDispatchWorkOrder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE d
SET d.[WorkOrderId] = w.[Id]
FROM [Dispatches] AS d
INNER JOIN [workOrders] AS w ON w.[PrimaryDispatchId] = d.[Id]
WHERE d.[WorkOrderId] IS NULL
AND NOT EXISTS (
SELECT 1 FROM [DispatchWorkOrders] AS l
WHERE l.[DispatchId] = d.[Id])
AND (
SELECT COUNT(*) FROM [workOrders] AS owner
WHERE owner.[PrimaryDispatchId] = d.[Id]) = 1;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The heal is not reversible: healed rows are indistinguishable from dispatches
// that were created with their work order set.
}
}
}

View file

@ -337,6 +337,25 @@ namespace SeaHaven.DataServices.Implementation
.AnyAsync(u => u.Status == "Pending" || u.Status == "ChangesRequested", cancellationToken);
}
public async Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(
int workOrderId,
int? primaryDispatchId,
CancellationToken cancellationToken)
{
var candidates = await _context.Dispatches
.Where(d =>
(d.IsDeleted == null || d.IsDeleted == false)
&& (
d.WorkOrderId == workOrderId
|| d.DispatchWorkOrders!.Any(link => link.WorkOrderId == workOrderId)))
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
.ThenByDescending(d => d.Id)
.ToListAsync(cancellationToken);
return candidates.FirstOrDefault(d => d.Id == primaryDispatchId)
?? candidates.FirstOrDefault();
}
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
{
return ForWorkOrder(workOrderId)

View file

@ -166,6 +166,25 @@ namespace SeaHaven.DataServices.Implementation
await transaction.CommitAsync(cancellationToken);
}
public async Task ExecuteTransactionalAsync(
Func<CancellationToken, Task> callback,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(callback);
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
await callback(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(CancellationToken.None);
throw;
}
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{

View file

@ -19,6 +19,10 @@ namespace SeaHaven.DataServices.Interfaces
Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// SH-393: the tracked dispatch a work-order uplift is written to, resolved through the
// same scope the work-order uplift reads use (non-deleted, owned or linked): the
// work order's primary dispatch when it qualifies, otherwise its latest qualifying one.
Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken);
Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken);
// Approval queue read contract: set-based per-work-order exposure totals covering
// auto-approved and admin-approved uplift amounts (pending, rejected,

View file

@ -18,6 +18,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}

View file

@ -52,10 +52,6 @@ public sealed class TeamMemberService : ITeamMemberService
if (await _userManager.FindByEmailAsync(email!) is not null)
return Failure("Email is already in use.");
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
return Failure(roleError);
var now = DateTime.UtcNow;
var user = new ApplicationUser
{
@ -72,39 +68,44 @@ public sealed class TeamMemberService : ITeamMemberService
PendingRegistrationCreatedDate = now
};
IdentityResult createResult;
try
{
createResult = await _userManager.CreateAsync(user);
}
catch (DbUpdateException)
{
// A concurrent create won the race on the unique user-name index
// between the FindByEmailAsync check above and this insert. Surface
// the same conflict message instead of letting the database
// exception bubble up as a 500.
return Failure("Email is already in use.");
}
await _userDataService.ExecuteTransactionalAsync(
async transactionCancellationToken =>
{
var roleError = await EnsureRoleAsync(role!);
if (roleError is not null)
throw new TeamMemberCreateException(roleError);
if (!createResult.Succeeded)
return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
IdentityResult createResult;
try
{
createResult = await _userManager.CreateAsync(user);
}
catch (DbUpdateException)
{
// A concurrent create won the race on the unique user-name index
// between the FindByEmailAsync check above and this insert. Surface
// the same conflict message instead of letting the database
// exception bubble up as a 500.
throw new TeamMemberCreateException("Email is already in use.");
}
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
{
await _userManager.DeleteAsync(user);
return Failure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
}
if (!createResult.Succeeded)
throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
try
{
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
if (!addRoleResult.Succeeded)
throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
},
cancellationToken);
}
catch
catch (TeamMemberCreateException exception)
{
await _userManager.DeleteAsync(user);
throw;
return Failure(exception.Message);
}
return new CreateTeamMemberOutcomeDTO
@ -391,6 +392,13 @@ public sealed class TeamMemberService : ITeamMemberService
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
new() { Success = false, Error = error };
private sealed class TeamMemberCreateException : Exception
{
public TeamMemberCreateException(string error) : base(error)
{
}
}
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
new() { Success = false, Error = error };
}

View file

@ -249,10 +249,14 @@ namespace SeaHaven.Services.Implementation
_mutationData.TrackNewWorkOrder(workOrder);
var firstOutcome = await _mutationData.SaveAsync(ct);
if (dispatch != null && workOrder.PrimaryDispatchId == null)
// SH-393: the first save already fixes up PrimaryDispatchId from the
// PrimaryDispatch navigation, so the dispatch's own WorkOrderId must be
// backfilled independently. Without it the dispatch belongs to no work order
// and every owned-or-linked read (uplifts, dispatch patches) misses it.
if (dispatch != null)
{
workOrder.PrimaryDispatchId = dispatch.Id;
dispatch.WorkOrderId = workOrder.Id;
workOrder.PrimaryDispatchId ??= dispatch.Id;
dispatch.WorkOrderId ??= workOrder.Id;
}

View file

@ -111,24 +111,16 @@ namespace SeaHaven.Services.Implementation
$"Cannot create an uplift on a '{workOrder.LifecycleStatus}' work order");
}
Dispatch? dispatch;
if (workOrder.PrimaryDispatchId is int primaryDispatchId)
{
dispatch = await _dispatchData.GetByIdAsync(primaryDispatchId);
if (dispatch == null)
throw new KeyNotFoundException("Dispatch not found");
}
else
{
dispatch = (await _dispatchData.GetByWorkOrderIdAsync(workOrderId, cancellationToken))
.Where(candidate => candidate.IsDeleted != true)
.OrderByDescending(candidate => candidate.DispatchedAt ?? candidate.CreatedDate)
.ThenByDescending(candidate => candidate.Id)
.FirstOrDefault();
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
}
// SH-393: write to a dispatch the work order's uplift reads resolve back to it.
// Loading the primary by id alone accepted soft-deleted, unlinked, or other work
// orders' dispatches, so the request never showed on this work order and never
// consumed its allowance.
var dispatch = await _upliftData.GetUpliftDispatchForWorkOrderAsync(
workOrderId,
workOrder.PrimaryDispatchId,
cancellationToken);
if (dispatch == null)
throw new InvalidOperationException("Work order has no primary dispatch for uplift requests");
if (IsTerminalForUplift(dispatch.Status))
throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch");

View file

@ -0,0 +1,230 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public sealed class TeamMemberCreateTransactionTests
{
[Fact]
public async Task Create_MidSequencePersistenceFailure_RollsBackAllMemberRows()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true);
ThrowingAfterPersistPermissionDataService failingPermissionData;
await using (var createScope = serviceProvider.CreateAsyncScope())
{
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
failingPermissionData = (ThrowingAfterPersistPermissionDataService)createScope.ServiceProvider
.GetRequiredService<ITeamPermissionOverrideDataService>();
var failure = await Record.ExceptionAsync(() => service.CreateAsync(
ValidRequest(),
Admin(),
CancellationToken.None));
Assert.IsType<InvalidOperationException>(failure);
Assert.Equal("injected mid-sequence failure", failure!.Message);
}
Assert.NotNull(failingPermissionData.RowsAtFailure);
var rowsAtFailure = failingPermissionData.RowsAtFailure!.Value;
Assert.Equal(1, rowsAtFailure.Users);
Assert.Equal(1, rowsAtFailure.Roles);
Assert.Equal(1, rowsAtFailure.UserRoles);
Assert.Equal(2, rowsAtFailure.ServiceAreas);
Assert.Equal(1, rowsAtFailure.PermissionOverrides);
await using var verifyScope = serviceProvider.CreateAsyncScope();
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Empty(await verify.Users.AsNoTracking().ToListAsync());
Assert.Empty(await verify.Roles.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
}
[Fact]
public async Task Create_SuccessfulTransaction_CommitsPendingMemberAndAssociations()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var setup = new SqliteTeamMemberTestDbContext(options))
await setup.Database.EnsureCreatedAsync();
await using var serviceProvider = BuildServiceProvider(connection);
await using (var createScope = serviceProvider.CreateAsyncScope())
{
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
var outcome = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None);
Assert.True(outcome.Success);
Assert.True(outcome.Member!.PendingRegistration);
Assert.Equal("taylor@example.com", outcome.Member.Email);
}
await using var verifyScope = serviceProvider.CreateAsyncScope();
var verify = verifyScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var user = await verify.Users.AsNoTracking().SingleAsync(user => user.Email == "taylor@example.com");
Assert.True(user.PendingRegistration);
var role = await verify.Roles.AsNoTracking().SingleAsync(role => role.Name == "Dispatcher");
Assert.Contains(await verify.UserRoles.AsNoTracking().ToListAsync(), row => row.UserId == user.Id && row.RoleId == role.Id);
var areas = await verify.UserServiceAreas.AsNoTracking()
.Where(area => area.UserId == user.Id)
.Select(area => area.Area)
.OrderBy(area => area)
.ToListAsync();
Assert.Equal(new[] { "East", "West" }, areas);
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
.SingleAsync(permission => permission.UserId == user.Id);
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
}
private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false)
{
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(builder => builder.UseSqlite(connection));
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteTeamMemberTestDbContext(
provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddDataServices();
services.AddBusinessServices(configuration);
if (injectFailure)
{
services.AddScoped<TeamPermissionOverrideDataService>();
services.AddScoped<ITeamPermissionOverrideDataService>(provider =>
new ThrowingAfterPersistPermissionDataService(
provider.GetRequiredService<TeamPermissionOverrideDataService>(),
provider.GetRequiredService<ApplicationDbContext>()));
}
return services.BuildServiceProvider();
}
private static CreateTeamMemberRequestDTO ValidRequest() => new()
{
Name = "Taylor Dispatcher",
Role = "dispatcher",
Color = "#F59E0B",
Email = "taylor@example.com",
Phone = "555-0100",
ServiceAreas = new[] { "east", "West" },
PermissionOverrides = new Dictionary<string, UserPermissionState>
{
["deleteSites"] = UserPermissionState.Allow
}
};
private static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
private sealed class SqliteTeamMemberTestDbContext : ApplicationDbContext
{
public SqliteTeamMemberTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
if (index.GetFilter() is not null)
index.SetFilter(null);
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
private sealed class ThrowingAfterPersistPermissionDataService : ITeamPermissionOverrideDataService
{
private readonly ITeamPermissionOverrideDataService _inner;
private readonly ApplicationDbContext _context;
public ThrowingAfterPersistPermissionDataService(
ITeamPermissionOverrideDataService inner,
ApplicationDbContext context)
{
_inner = inner;
_context = context;
}
public (int Users, int Roles, int UserRoles, int ServiceAreas, int PermissionOverrides)? RowsAtFailure { get; private set; }
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken) =>
_inner.GetUserAsync(userId, cancellationToken);
public Task SetOverrideAsync(
string userId,
string permissionKey,
UserPermissionState state,
CancellationToken cancellationToken) =>
_inner.SetOverrideAsync(userId, permissionKey, state, cancellationToken);
public async Task SetOverridesAsync(
string userId,
IReadOnlyDictionary<string, UserPermissionState> overrides,
CancellationToken cancellationToken)
{
await _inner.SetOverridesAsync(userId, overrides, cancellationToken);
RowsAtFailure = (
await _context.Users.CountAsync(cancellationToken),
await _context.Roles.CountAsync(cancellationToken),
await _context.UserRoles.CountAsync(cancellationToken),
await _context.UserServiceAreas.CountAsync(cancellationToken),
await _context.UserPermissionOverrides.CountAsync(cancellationToken));
throw new InvalidOperationException("injected mid-sequence failure");
}
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
_inner.ClearOverridesAsync(userId, cancellationToken);
}
}

View file

@ -442,6 +442,7 @@ public class WorkOrderBoardCancelServiceTests
public Task<UpliftEvidenceDownloadData?> GetEvidenceForInternalDownloadAsync(int upliftRequestId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<bool> HasPendingForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<Dispatch?> GetUpliftDispatchForWorkOrderAsync(int workOrderId, int? primaryDispatchId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> SumAutoApprovedAmountForWorkOrderAsync(int workOrderId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<WorkOrderUpliftExposureData>> GetApprovedExposureForWorkOrdersAsync(IReadOnlyCollection<int> workOrderIds, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<decimal> GetPendingExposureTotalAsync(CancellationToken cancellationToken) => throw new NotSupportedException();

View file

@ -134,6 +134,72 @@ public class WorkOrderBoardCreateRelationalTests
Assert.Empty(await verify.Dispatches.AsNoTracking().ToListAsync());
}
[Fact]
public async Task Create_WithVendor_PersistsPrimaryDispatchOwnershipForUpliftResolution()
{
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
await using (var context = new SqliteBoardTestDbContext(options))
{
await context.Database.EnsureCreatedAsync();
context.Users.Add(new ApplicationUser
{
Id = "actor-1",
UserName = "actor-1",
NormalizedUserName = "ACTOR-1",
Email = "actor@test.local",
NormalizedEmail = "ACTOR@TEST.LOCAL"
});
await context.SaveChangesAsync();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context);
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context);
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
await context.SaveChangesAsync();
}
WorkOrderBoardRowDto result;
await using (var context = new SqliteBoardTestDbContext(options))
{
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var service = new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
VendorId = 5
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
}
// SH-393: fresh context, committed state only. The primary dispatch must be owned
// by the work order so the uplift create/read scope resolves it.
await using var verify = new SqliteBoardTestDbContext(options);
var dispatch = await verify.Dispatches.AsNoTracking().SingleAsync();
Assert.Equal(result.PrimaryDispatchId, dispatch.Id);
Assert.Equal(result.Id, dispatch.WorkOrderId);
var upliftDispatch = await new UpliftDataService(verify).GetUpliftDispatchForWorkOrderAsync(
result.Id,
result.PrimaryDispatchId,
CancellationToken.None);
Assert.Equal(dispatch.Id, upliftDispatch?.Id);
}
private sealed class SqliteBoardTestDbContext : ApplicationDbContext
{
public SqliteBoardTestDbContext(DbContextOptions<ApplicationDbContext> options)

View file

@ -0,0 +1,344 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// SH-393: an uplift created from a work order must land on a dispatch the work order's
/// uplift reads resolve back to it (non-deleted, owned or linked), so the request is
/// listed on that work order, consumes its allowance and carries its WO number in the
/// approval queue.
/// </summary>
public sealed class WorkOrderUpliftDispatchOwnershipTests
{
private const string NoPrimaryDispatchMessage = "Work order has no primary dispatch for uplift requests";
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
WorkOrderAccountTestHelpers.SeedBoardCreateScope(context);
context.Vendors.Add(new Vendor { Id = 5, CompanyName = "Acme HVAC" });
context.SaveChanges();
return context;
}
private static WorkOrderBoardCreateService NewBoardCreateService(ApplicationDbContext context)
{
var boardData = new WorkOrderBoardDataService(context);
var resolver = WorkOrderAccountTestHelpers.Resolver(context);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardCreateService(
boardData,
new WorkOrderBoardMutationDataService(context),
new WorkOrderBoardService(boardData, resolver),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks),
new WorkOrderBoardCreateValidation(),
resolver,
new WorkOrderPocDataService(context));
}
private static WorkOrderBoardUpdateService NewBoardUpdateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
return new WorkOrderBoardUpdateService(
new WorkOrderBoardDataService(context),
new WorkOrderBoardMutationDataService(context),
new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks));
}
private static WorkOrderUpliftService NewUpliftService(ApplicationDbContext context)
{
return new WorkOrderUpliftService(
new UpliftDataService(context),
new DispatchDataService(context),
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
TimeProvider.System,
Options.Create(new ApprovalsOptions
{
UpliftTier1MaxUsd = 2500m,
Tier1Roles = new[] { "Approver" },
Tier2Roles = new[] { "Manager" },
}));
}
private static Task<WorkOrderBoardRowDto> BoardCreateWithVendorAsync(
ApplicationDbContext context,
bool? isAddOn = null)
{
return NewBoardCreateService(context).CreateAsync(new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 1,
VendorId = 5,
IsAddOn = isAddOn,
}, WorkOrderAccountTestHelpers.AccountUser(), "actor-1");
}
private static Task<WorkOrderUpliftDto?> CreateUpliftAsync(
ApplicationDbContext context,
int workOrderId,
decimal amount,
int accountId = 1)
{
return NewUpliftService(context).CreateAsync(
workOrderId,
new CreateWorkOrderUpliftRequestDto { Amount = amount, Notes = "Extra coil work" },
WorkOrderAccountTestHelpers.AccountUser(accountId: accountId),
CancellationToken.None);
}
private static async Task<(int WorkOrderId, int PrimaryDispatchId)> SeedWorkOrderAsync(
ApplicationDbContext context,
Dispatch primary,
params Dispatch[] others)
{
context.Dispatches.Add(primary);
context.Dispatches.AddRange(others);
context.workOrders.Add(new WorkOrder
{
Id = 1,
InternalWONumber = "SH00001",
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
PrimaryDispatchId = primary.Id,
});
await context.SaveChangesAsync();
return (1, primary.Id);
}
[Fact]
public async Task BoardCreate_WithVendor_PrimaryDispatchBelongsToWorkOrder()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context);
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
Assert.Equal(row.Id, dispatch.WorkOrderId);
}
[Fact]
public async Task BoardCreatedWorkOrder_PatchApptDate_KeepsPrimaryDispatch()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context);
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
var dispatch = await context.Dispatches.SingleAsync(d => d.Id == row.PrimaryDispatchId);
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
dispatch.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 };
await context.SaveChangesAsync();
var patched = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ApptDate,
Value = "2026-10-05",
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
DispatchVersion = Convert.ToBase64String(dispatch.RowVersion),
}, "actor-1");
Assert.Equal(row.PrimaryDispatchId, patched.PrimaryDispatchId);
Assert.Equal(1, await context.Dispatches.CountAsync());
}
[Fact]
public async Task BoardCreatedWorkOrder_AutoApprovedUplift_IsListedAndConsumesAllowance()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context);
var created = await CreateUpliftAsync(context, row.Id, 400m);
Assert.Equal("auto_approved", created!.Status);
var listed = await NewUpliftService(context).ListAsync(
row.Id,
WorkOrderAccountTestHelpers.AccountUser(),
CancellationToken.None);
var uplift = Assert.Single(listed!);
Assert.Equal(created.Id, uplift.Id);
Assert.Equal(400m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
}
[Fact]
public async Task BoardCreatedWorkOrder_RequestBeyondRemainingAllowance_GoesToPendingWithWorkOrderNumber()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context);
await CreateUpliftAsync(context, row.Id, 400m);
var second = await CreateUpliftAsync(context, row.Id, 200m);
Assert.Equal("pending", second!.Status);
var (_, queue) = await new UpliftDataService(context)
.GetPagedAsync("Pending", null, 1, 25, CancellationToken.None);
var queued = Assert.Single(queue);
Assert.Equal(second.Id, queued.Id);
Assert.Equal(row.Id, queued.WorkOrderId);
Assert.Equal(row.WoNumber, queued.WorkOrderNumber);
}
[Fact]
public async Task BoardCreatedWorkOrder_OwnerRevokesAutoApprovedUplift()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context);
var created = await CreateUpliftAsync(context, row.Id, 1m);
var revoked = await NewUpliftService(context).RevokeAsync(
row.Id,
created!.Id,
new RevokeWorkOrderUpliftRequestDto(),
WorkOrderAccountTestHelpers.AccountUser(),
CancellationToken.None);
Assert.Equal("revoked", revoked!.Status);
Assert.Equal(0m, await new UpliftDataService(context)
.SumAutoApprovedAmountForWorkOrderAsync(row.Id, CancellationToken.None));
}
[Fact]
public async Task ExAddOnWorkOrder_AfterScheduleClearsAddOn_UpliftIsListedOnWorkOrder()
{
await using var context = CreateContext();
var row = await BoardCreateWithVendorAsync(context, isAddOn: true);
Assert.True(row.IsAddOn);
var workOrder = await context.workOrders.SingleAsync(w => w.Id == row.Id);
workOrder.RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 };
await context.SaveChangesAsync();
var rescheduled = await NewBoardUpdateService(context).PatchFieldAsync(row.Id, new WorkOrderBoardPatchRequestDto
{
Field = WorkOrderBoardFieldNames.ScheduledDate,
Value = DateTime.UtcNow.Date.AddDays(60).ToString("yyyy-MM-dd"),
WorkOrderVersion = Convert.ToBase64String(workOrder.RowVersion),
}, "actor-1");
Assert.False(rescheduled.IsAddOn);
var created = await CreateUpliftAsync(context, row.Id, 1m);
var listed = await NewUpliftService(context).ListAsync(
row.Id,
WorkOrderAccountTestHelpers.AccountUser(),
CancellationToken.None);
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
}
[Fact]
public async Task Create_SoftDeletedPrimaryDispatch_UsesOwnedLiveDispatch()
{
await using var context = CreateContext();
var (workOrderId, _) = await SeedWorkOrderAsync(
context,
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true },
new Dispatch { Id = 11, VendorId = 5, WorkOrderId = 1, NTEAmount = 800m });
var created = await CreateUpliftAsync(context, workOrderId, 100m);
var request = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == created!.Id);
Assert.Equal(11, request.DispatchId);
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
Assert.Equal(900m, (await context.Dispatches.SingleAsync(d => d.Id == 11)).NTEAmount);
}
[Fact]
public async Task Create_OnlySoftDeletedPrimaryDispatch_IsRejectedWithoutPersisting()
{
await using var context = CreateContext();
var (workOrderId, _) = await SeedWorkOrderAsync(
context,
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 1, NTEAmount = 1000m, IsDeleted = true });
var error = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateUpliftAsync(context, workOrderId, 100m));
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Empty(context.WorkOrderAuditLogs);
}
[Fact]
public async Task Create_PrimaryDispatchOwnedByAnotherWorkOrder_IsNotUsed()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
var (workOrderId, _) = await SeedWorkOrderAsync(
context,
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
var error = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateUpliftAsync(context, workOrderId, 100m));
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, (await context.Dispatches.SingleAsync(d => d.Id == 10)).NTEAmount);
}
[Fact]
public async Task Create_UnlinkedPrimaryDispatch_IsRejectedInsteadOfHidingTheUplift()
{
await using var context = CreateContext();
var (workOrderId, _) = await SeedWorkOrderAsync(
context,
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = null, NTEAmount = 1000m });
var error = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateUpliftAsync(context, workOrderId, 100m));
Assert.Equal(NoPrimaryDispatchMessage, error.Message);
Assert.Empty(context.DispatchUpliftRequests);
}
[Fact]
public async Task Create_PrimaryDispatchLinkedThroughDispatchWorkOrders_IsUsed()
{
await using var context = CreateContext();
context.workOrders.Add(new WorkOrder { Id = 2, InternalWONumber = "SH00002", AccountId = 1 });
var (workOrderId, _) = await SeedWorkOrderAsync(
context,
new Dispatch { Id = 10, VendorId = 5, WorkOrderId = 2, NTEAmount = 1000m });
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = 10, WorkOrderId = workOrderId });
await context.SaveChangesAsync();
var created = await CreateUpliftAsync(context, workOrderId, 100m);
var listed = await NewUpliftService(context).ListAsync(
workOrderId,
WorkOrderAccountTestHelpers.AccountUser(),
CancellationToken.None);
Assert.Equal(created!.Id, Assert.Single(listed!).Id);
}
[Fact]
public async Task CrossTenant_CreateAndList_AreRejected()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, id: 2, name: "Other Corp");
var row = await BoardCreateWithVendorAsync(context);
await CreateUpliftAsync(context, row.Id, 1m);
var created = await CreateUpliftAsync(context, row.Id, 1m, accountId: 2);
var listed = await NewUpliftService(context).ListAsync(
row.Id,
WorkOrderAccountTestHelpers.AccountUser(accountId: 2),
CancellationToken.None);
Assert.Null(created);
Assert.Null(listed);
Assert.Equal(1, await context.DispatchUpliftRequests.CountAsync());
}
}

View file

@ -289,7 +289,21 @@ public sealed class WorkOrderUpliftServiceTests
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
dispatch.WorkOrderId = null;
// SH-393: an unlinked primary is no longer an uplift target, so the shared-dispatch
// case (owned by another work order, linked to this one) carries the duplicate check.
context.workOrders.Add(new WorkOrder
{
Id = 2,
InternalWONumber = "10000000002",
AccountId = 1,
WorkOrderType = WorkOrderType.PM,
});
dispatch.WorkOrderId = 2;
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id,
});
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
{
Id = 100,