From 2e56ec7678017ae4ffd024b0e818b058a7049062 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 10:00:02 -0300 Subject: [PATCH] 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. --- .../LocationServiceTests.cs | 67 ++++++- .../Controllers/LocationController.cs | 6 +- .../Controllers/WorkOrderBoardController.cs | 6 +- .../DTOs/EditLocation_DTO.cs | 3 +- Api.SeaHavenIndustries/DTOs/Location_DTO.cs | 4 +- ...260826120000_SH221_LocationAccountScope.cs | 4 + .../WorkOrderBoardDataService.cs | 7 +- .../Interfaces/IWorkOrderBoardDataService.cs | 5 +- .../Implementation/LocationService.cs | 6 +- .../WorkOrderBoardCreateService.cs | 21 ++- .../WorkOrderBoardUpdateService.cs | 15 +- .../IWorkOrderBoardCreateService.cs | 3 +- .../Validation/LocationValidation.cs | 8 - ...SH221LocationAccountScopeSqlServerTests.cs | 172 ++++++++++++++++++ .../WorkOrderAccountScopeTests.cs | 83 +++++++++ .../WorkOrderMigrationDiscoveryTests.cs | 13 ++ 16 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index e6d97bf..844b28f 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -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(); } + [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() { diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index dd91983..977bdda 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -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 }; } } diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs index c45b1c6..f991f56 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderBoardController.cs @@ -166,12 +166,14 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost("board")] - public async Task CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request) + public async Task 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) diff --git a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs index 18d1be3..43ad77c 100644 --- a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs @@ -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 }; } } diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index c458bf8..6e38315 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -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; } diff --git a/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs index 06d81bd..17e91bb 100644 --- a/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs +++ b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs @@ -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 { /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260826120000_SH221_LocationAccountScope")] public partial class SH221_LocationAccountScope : Migration { /// diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index ba5af55..5a9d841 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -84,7 +84,10 @@ namespace SeaHaven.DataServices.Implementation return rows.FirstOrDefault(); } - public async Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId) + public async Task 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); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs index 28d5773..df62e3b 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderBoardDataService.cs @@ -12,6 +12,9 @@ namespace SeaHaven.DataServices.Interfaces int workOrderId, int? accountId = null, CancellationToken cancellationToken = default); - Task InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId); + Task InternalWoNumberExistsAsync( + string normalizedWoNumber, + int excludeWorkOrderId, + CancellationToken cancellationToken = default); } } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 2a8fbcf..6be9d0f 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -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); } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index c584b75..37adcc0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -40,18 +40,19 @@ namespace SeaHaven.Services.Implementation public async Task 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 ResolveWoNumberAsync(string? requested) + private async Task 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; } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 6c27693..b8c94e8 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -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 { await ApplyWoNumber(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.WoNumber => new List { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) }, WorkOrderBoardFieldNames.WorkOrderType => new List { ApplyWorkOrderType(workOrder, value, auditField) }, WorkOrderBoardFieldNames.SiteCode => new List { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) }, WorkOrderBoardFieldNames.LifecycleStatus => new List { ApplyLifecycleStatus(workOrder, value, auditField) }, @@ -278,12 +279,16 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardConcurrencyException(currentState); } - private async Task ApplyWoNumber(WorkOrder workOrder, string? value, string auditField) + private async Task 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; diff --git a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs index 8745ced..9c2a0ca 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderBoardCreateService.cs @@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces Task CreateAsync( WorkOrderBoardCreateRequestDto request, ClaimsPrincipal user, - string? actorId); + string? actorId, + CancellationToken cancellationToken = default); } } diff --git a/SeaHaven.Services/Validation/LocationValidation.cs b/SeaHaven.Services/Validation/LocationValidation.cs index 4c9cc97..d5e181c 100644 --- a/SeaHaven.Services/Validation/LocationValidation.cs +++ b/SeaHaven.Services/Validation/LocationValidation.cs @@ -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); } } } diff --git a/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs b/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs new file mode 100644 index 0000000..444575c --- /dev/null +++ b/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs @@ -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() + .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(); + var commands = sqlGenerator.Generate(builder.Operations, model: null); + foreach (var command in commands) + await context.Database.ExecuteSqlRawAsync(command.CommandText); + } + + private static async Task 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. + } + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 85086b2..067234e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -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 GetByIdAsync(int id) => _inner.GetByIdAsync(id); + public Task GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id); + public Task> GetAllAsync() => _inner.GetAllAsync(); + public Task> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId); + public Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null) + => _inner.GetPagedAsync(page, pageSize, search); + public Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null) + => _inner.GetAddressbookPagedAsync(page, pageSize, search); + public Task> 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 AddAsync(Locations location) => _inner.AddAsync(location); + public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location); + public Task DeleteAsync(int id) => _inner.DeleteAsync(id); + public Task ExistsAsync(int id) => _inner.ExistsAsync(id); + public Task CountAsync() => _inner.CountAsync(); + public Task<(List Items, int TotalCount)> GetListPagedAsync( + int page, int pageSize, string? search, CancellationToken cancellationToken) + => _inner.GetListPagedAsync(page, pageSize, search, cancellationToken); + public Task GetDetailByIdAsync(int id, CancellationToken cancellationToken) + => _inner.GetDetailByIdAsync(id, cancellationToken); + public Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) + => _inner.GetByIdForUpdateAsync(id, cancellationToken); + public Task AddAsync(Locations location, CancellationToken cancellationToken) + => _inner.AddAsync(location, cancellationToken); + public Task UpdateAsync(Locations location, CancellationToken cancellationToken) + => _inner.UpdateAsync(location, cancellationToken); + public Task DeleteByIdAsync(int id, CancellationToken cancellationToken) + => _inner.DeleteByIdAsync(id, cancellationToken); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs index 8e34d94..30258a6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderMigrationDiscoveryTests.cs @@ -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() + .UseSqlite("DataSource=:memory:") + .Options; + + using var context = new ApplicationDbContext(options); + var migrations = context.Database.GetMigrations().ToList(); + + Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations); + } }