mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
fix(work-orders): keep location account server-owned and forward create cancellation
Stop client writes from changing Locations.AccountId, make the SH-221 migration discoverable, and thread the board-create CancellationToken through lookup and persistence.
This commit is contained in:
parent
61923b2a7d
commit
2e56ec7678
16 changed files with 379 additions and 44 deletions
|
|
@ -52,7 +52,7 @@ public class LocationServiceTests
|
|||
|
||||
var entity = ctx.Locations.Single();
|
||||
entity.Name.Should().Be("Warehouse");
|
||||
entity.AccountId.Should().Be(9);
|
||||
entity.AccountId.Should().BeNull();
|
||||
entity.Title.Should().Be("Main WH");
|
||||
entity.Address1.Should().Be("1 Depot Rd");
|
||||
entity.City.Should().Be("Austin");
|
||||
|
|
@ -119,6 +119,71 @@ public class LocationServiceTests
|
|||
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Old", "Round Rock");
|
||||
existing.AccountId = 4;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "New",
|
||||
City = "Plano"
|
||||
}, CancellationToken.None);
|
||||
|
||||
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||
existing.AccountId = 4;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "Owned",
|
||||
AccountId = 99
|
||||
}, CancellationToken.None);
|
||||
|
||||
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateLocationAsync_IgnoresClientAccountId()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO
|
||||
{
|
||||
LocationName = "Site",
|
||||
AccountId = 9
|
||||
}, "42");
|
||||
|
||||
created.AccountId.Should().BeNull();
|
||||
ctx.Locations.Single().AccountId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Owned", "Austin");
|
||||
existing.AccountId = 4;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO
|
||||
{
|
||||
LocationName = "Owned",
|
||||
AccountId = 99
|
||||
}, "42");
|
||||
|
||||
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -177,8 +177,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ZipCode = model.ZipCode,
|
||||
Phone = model.Phone,
|
||||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId()
|
||||
Status = model.Status
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +193,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ZipCode = model.ZipCode,
|
||||
Phone = model.Phone,
|
||||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId()
|
||||
Status = model.Status
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,12 +166,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
|
||||
[HttpPost("board")]
|
||||
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
|
||||
public async Task<IActionResult> CreateBoardWorkOrder(
|
||||
[FromBody] WorkOrderBoardCreateRequestDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var row = await _boardCreateService.CreateAsync(request, User, actorId);
|
||||
var row = await _boardCreateService.CreateAsync(request, User, actorId, cancellationToken);
|
||||
return Ok(row);
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
Address = this.Address,
|
||||
City = this.City,
|
||||
State = this.State,
|
||||
Zipcode = this.ZipCode,
|
||||
AccountId = this.GetAccountId()
|
||||
Zipcode = this.ZipCode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
Address = this.Address,
|
||||
City = this.City,
|
||||
State = this.State,
|
||||
Zipcode = this.ZipCode,
|
||||
AccountId = this.GetAccountId()
|
||||
Zipcode = this.ZipCode
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +67,6 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
// Contact field doesn't exist in database schema - ignored
|
||||
entity.Email = dto.ContactEmail;
|
||||
entity.Status = dto.Status;
|
||||
entity.AccountId = dto.GetAccountId();
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
|
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
|||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260826120000_SH221_LocationAccountScope")]
|
||||
public partial class SH221_LocationAccountScope : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ namespace SeaHaven.DataServices.Implementation
|
|||
return rows.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
|
||||
public async Task<bool> InternalWoNumberExistsAsync(
|
||||
string normalizedWoNumber,
|
||||
int excludeWorkOrderId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.workOrders
|
||||
.AsNoTracking()
|
||||
|
|
@ -92,7 +95,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
w.Id != excludeWorkOrderId
|
||||
&& w.istemplate != true
|
||||
&& (w.IsDeleted != true || w.IsDeleted == null)
|
||||
&& w.InternalWONumber == normalizedWoNumber);
|
||||
&& w.InternalWONumber == normalizedWoNumber, cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
int workOrderId,
|
||||
int? accountId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
|
||||
Task<bool> InternalWoNumberExistsAsync(
|
||||
string normalizedWoNumber,
|
||||
int excludeWorkOrderId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ namespace SeaHaven.Services.Implementation
|
|||
City = dto.City,
|
||||
State = dto.State,
|
||||
Zip = dto.Zipcode,
|
||||
AccountId = dto.AccountId,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
createdby = userId
|
||||
};
|
||||
|
|
@ -122,7 +121,6 @@ namespace SeaHaven.Services.Implementation
|
|||
if (dto.City != null) location.City = dto.City;
|
||||
if (dto.State != null) location.State = dto.State;
|
||||
if (dto.Zipcode != null) location.Zip = dto.Zipcode;
|
||||
if (dto.AccountId.HasValue) location.AccountId = dto.AccountId;
|
||||
|
||||
location.LastModificationTime = DateTime.UtcNow;
|
||||
if (int.TryParse(userId, out int userIdInt))
|
||||
|
|
@ -189,8 +187,7 @@ namespace SeaHaven.Services.Implementation
|
|||
Zip = request.ZipCode,
|
||||
PhoneNumber = request.Phone,
|
||||
Email = request.ContactEmail,
|
||||
Status = request.Status,
|
||||
AccountId = request.AccountId
|
||||
Status = request.Status
|
||||
};
|
||||
|
||||
await _locationDataService.AddAsync(location, cancellationToken);
|
||||
|
|
@ -211,7 +208,6 @@ namespace SeaHaven.Services.Implementation
|
|||
location.PhoneNumber = request.Phone;
|
||||
location.Email = request.ContactEmail;
|
||||
location.Status = request.Status;
|
||||
location.AccountId = request.AccountId;
|
||||
|
||||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,18 +40,19 @@ namespace SeaHaven.Services.Implementation
|
|||
public async Task<WorkOrderBoardRowDto> CreateAsync(
|
||||
WorkOrderBoardCreateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId)
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var validationResult = await _validator.ValidateAsync(request);
|
||||
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
|
||||
if (!validationResult.IsValid)
|
||||
throw new ValidationException(validationResult.Errors);
|
||||
|
||||
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
|
||||
user,
|
||||
request.LocationId,
|
||||
CancellationToken.None);
|
||||
cancellationToken);
|
||||
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
|
||||
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
|
||||
var siteCode = request.SiteCode!.Trim();
|
||||
var primaryService = ResolvePrimaryService(request);
|
||||
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
|
||||
|
|
@ -69,7 +70,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
if (request.VendorId.HasValue && request.VendorId.Value > 0)
|
||||
{
|
||||
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None))
|
||||
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, cancellationToken))
|
||||
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +224,7 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
|
||||
await _mutationData.SaveAsync(ct);
|
||||
}, CancellationToken.None);
|
||||
}, cancellationToken);
|
||||
|
||||
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
|
||||
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
|
||||
|
|
@ -274,20 +275,20 @@ namespace SeaHaven.Services.Implementation
|
|||
private static string? TrimOrNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private async Task<string> ResolveWoNumberAsync(string? requested)
|
||||
private async Task<string> ResolveWoNumberAsync(string? requested, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0, cancellationToken))
|
||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
|
||||
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(cancellationToken);
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
string candidate;
|
||||
|
|
@ -300,7 +301,7 @@ namespace SeaHaven.Services.Implementation
|
|||
break;
|
||||
}
|
||||
|
||||
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
|
||||
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0, cancellationToken))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ namespace SeaHaven.Services.Implementation
|
|||
}
|
||||
|
||||
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
|
||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
|
||||
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
|
||||
if (resolved is { Created: true, Dispatch: var createdDispatch }
|
||||
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
|
@ -170,11 +170,12 @@ namespace SeaHaven.Services.Implementation
|
|||
WorkOrder workOrder,
|
||||
Dispatch? dispatch,
|
||||
string? value,
|
||||
string auditField)
|
||||
string auditField,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return field switch
|
||||
{
|
||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
|
||||
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
|
||||
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
|
||||
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
|
||||
|
|
@ -278,12 +279,16 @@ namespace SeaHaven.Services.Implementation
|
|||
throw new WorkOrderBoardConcurrencyException(currentState);
|
||||
}
|
||||
|
||||
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
|
||||
private async Task<FieldChange> ApplyWoNumber(
|
||||
WorkOrder workOrder,
|
||||
string? value,
|
||||
string auditField,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
|
||||
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
|
||||
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
|
||||
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
|
||||
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
|
||||
|
||||
var old = workOrder.InternalWONumber;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<WorkOrderBoardRowDto> CreateAsync(
|
||||
WorkOrderBoardCreateRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId);
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.Zipcode)
|
||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||
|
||||
RuleFor(x => x.AccountId)
|
||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
||||
.When(x => x.AccountId.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,10 +60,6 @@ namespace SeaHaven.Services.Validation
|
|||
RuleFor(x => x.Zipcode)
|
||||
.MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters")
|
||||
.When(x => !string.IsNullOrEmpty(x.Zipcode));
|
||||
|
||||
RuleFor(x => x.AccountId)
|
||||
.GreaterThan(0).WithMessage("Valid account must be selected")
|
||||
.When(x => x.AccountId.HasValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
using System.Reflection;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
public class SH221LocationAccountScopeSqlServerTests
|
||||
{
|
||||
private const string LocalDbMaster =
|
||||
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
|
||||
|
||||
[Fact]
|
||||
public async Task SH221_LocationAccountScope_ApplyOnSqlServer_WhenLocalDbAvailable()
|
||||
{
|
||||
if (!await IsLocalDbAvailableAsync())
|
||||
return;
|
||||
|
||||
var dbName = $"SH221LocationAccount_{Guid.NewGuid():N}";
|
||||
var connectionString =
|
||||
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var createTables = connection.CreateCommand())
|
||||
{
|
||||
createTables.CommandText =
|
||||
"""
|
||||
CREATE TABLE Accounts (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE Locations (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE workOrders (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
LocationId int NULL,
|
||||
AccountId int NULL
|
||||
);
|
||||
SET IDENTITY_INSERT Accounts ON;
|
||||
INSERT INTO Accounts (Id) VALUES (1);
|
||||
SET IDENTITY_INSERT Accounts OFF;
|
||||
SET IDENTITY_INSERT Locations ON;
|
||||
INSERT INTO Locations (Id) VALUES (11);
|
||||
SET IDENTITY_INSERT Locations OFF;
|
||||
INSERT INTO workOrders (LocationId, AccountId) VALUES (11, 1);
|
||||
""";
|
||||
await createTables.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await ApplyMigrationUpAsync(context, new SH221_LocationAccountScope());
|
||||
|
||||
await using (var assertCmd = connection.CreateCommand())
|
||||
{
|
||||
assertCmd.CommandText =
|
||||
"""
|
||||
SELECT c.name
|
||||
FROM sys.columns c
|
||||
WHERE c.object_id = OBJECT_ID(N'Locations')
|
||||
AND c.name = N'AccountId';
|
||||
""";
|
||||
var column = await assertCmd.ExecuteScalarAsync();
|
||||
Assert.Equal("AccountId", column);
|
||||
}
|
||||
|
||||
await using (var indexCmd = connection.CreateCommand())
|
||||
{
|
||||
indexCmd.CommandText =
|
||||
"""
|
||||
SELECT name
|
||||
FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID(N'Locations')
|
||||
AND name = N'IX_Locations_AccountId';
|
||||
""";
|
||||
var index = await indexCmd.ExecuteScalarAsync();
|
||||
Assert.Equal("IX_Locations_AccountId", index);
|
||||
}
|
||||
|
||||
await using (var fkCmd = connection.CreateCommand())
|
||||
{
|
||||
fkCmd.CommandText =
|
||||
"""
|
||||
SELECT name
|
||||
FROM sys.foreign_keys
|
||||
WHERE parent_object_id = OBJECT_ID(N'Locations')
|
||||
AND name = N'FK_Locations_Accounts_AccountId';
|
||||
""";
|
||||
var fk = await fkCmd.ExecuteScalarAsync();
|
||||
Assert.Equal("FK_Locations_Accounts_AccountId", fk);
|
||||
}
|
||||
|
||||
await using (var backfillCmd = connection.CreateCommand())
|
||||
{
|
||||
backfillCmd.CommandText = "SELECT AccountId FROM Locations WHERE Id = 11;";
|
||||
var stamped = await backfillCmd.ExecuteScalarAsync();
|
||||
Assert.Equal(1, Convert.ToInt32(stamped));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DropDatabaseAsync(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Migration.Up not found.");
|
||||
up.Invoke(migration, [builder]);
|
||||
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
var commands = sqlGenerator.Generate(builder.Operations, model: null);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
|
||||
private static async Task<bool> IsLocalDbAvailableAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
return true;
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DropDatabaseAsync(string dbName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(LocalDbMaster);
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"""
|
||||
IF DB_ID(N'{dbName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{dbName}];
|
||||
END
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup for ephemeral LocalDB databases.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using Data.SeaHavenIndustries;
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
|
|
@ -216,6 +217,38 @@ public class WorkOrderAccountScopeTests
|
|||
Assert.Equal(3, wo.AccountId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BoardCreate_ForwardsCancellationToken_ToLocationAccountLookup()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
|
||||
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
|
||||
|
||||
var locations = new RecordingLocationDataService(new LocationDataService(context));
|
||||
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), locations);
|
||||
var boardData = new WorkOrderBoardDataService(context);
|
||||
var mutationData = new WorkOrderBoardMutationDataService(context);
|
||||
var boardService = new WorkOrderBoardService(boardData, resolver);
|
||||
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
|
||||
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
|
||||
var create = new WorkOrderBoardCreateService(
|
||||
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await create.CreateAsync(
|
||||
new WorkOrderBoardCreateRequestDto
|
||||
{
|
||||
WorkOrderType = WorkOrderType.PM,
|
||||
SiteCode = "BK5",
|
||||
LocationId = 11
|
||||
},
|
||||
WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"),
|
||||
"actor-1",
|
||||
cts.Token);
|
||||
|
||||
Assert.Equal(cts.Token, locations.LastScopeToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
|
||||
{
|
||||
|
|
@ -763,4 +796,54 @@ public class WorkOrderAccountScopeTests
|
|||
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
|
||||
Assert.Equal("Forbidden", missingEx.Code);
|
||||
}
|
||||
|
||||
private sealed class RecordingLocationDataService : ILocationDataService
|
||||
{
|
||||
private readonly ILocationDataService _inner;
|
||||
|
||||
public RecordingLocationDataService(ILocationDataService inner)
|
||||
{
|
||||
_inner = inner;
|
||||
}
|
||||
|
||||
public CancellationToken LastScopeToken { get; private set; }
|
||||
|
||||
public Task<Locations?> GetByIdAsync(int id) => _inner.GetByIdAsync(id);
|
||||
public Task<Locations?> GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id);
|
||||
public Task<IEnumerable<Locations>> GetAllAsync() => _inner.GetAllAsync();
|
||||
public Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId);
|
||||
public Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null)
|
||||
=> _inner.GetPagedAsync(page, pageSize, search);
|
||||
public Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null)
|
||||
=> _inner.GetAddressbookPagedAsync(page, pageSize, search);
|
||||
public Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
|
||||
=> _inner.GetSiteOptionsAsync(search);
|
||||
|
||||
public Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
|
||||
int locationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastScopeToken = cancellationToken;
|
||||
return _inner.GetAccountScopeAsync(locationId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Locations> AddAsync(Locations location) => _inner.AddAsync(location);
|
||||
public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location);
|
||||
public Task DeleteAsync(int id) => _inner.DeleteAsync(id);
|
||||
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
|
||||
public Task<int> CountAsync() => _inner.CountAsync();
|
||||
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||
int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
|
||||
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||
=> _inner.GetDetailByIdAsync(id, cancellationToken);
|
||||
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
|
||||
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
||||
=> _inner.AddAsync(location, cancellationToken);
|
||||
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
||||
=> _inner.UpdateAsync(location, cancellationToken);
|
||||
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||
=> _inner.DeleteByIdAsync(id, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,4 +32,17 @@ public class WorkOrderMigrationDiscoveryTests
|
|||
|
||||
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SH221_LocationAccountScope_is_discoverable_by_ef_runtime()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
using var context = new ApplicationDbContext(options);
|
||||
var migrations = context.Database.GetMigrations().ToList();
|
||||
|
||||
Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue