From 61923b2a7d1d4a3b798c12c1903b2179dd832aa5 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 09:12:48 -0300 Subject: [PATCH 1/3] feat(work-orders): stamp board create account from location Org-wide create no longer depends on customer name. POST /workorders/board requires locationId and stamps WorkOrder.AccountId from Location.AccountId. --- .../LocationServiceTests.cs | 4 +- .../Controllers/LocationController.cs | 12 +- Api.SeaHavenIndustries/DTOs/Location_DTO.cs | 4 +- .../Auth/ApplicationDbContext.cs | 7 + ...260826120000_SH221_LocationAccountScope.cs | 68 ++++++++ .../ApplicationDbContextModelSnapshot.cs | 17 ++ Data.SeaHavenIndustries/Models/Locations.cs | 7 +- .../Implementation/LocationDataService.cs | 20 ++- .../Interfaces/ILocationDataService.cs | 7 + SeaHaven.Services/DTOs/LocationDTOs.cs | 3 + .../DTOs/WorkOrderBoardRequestDTOs.cs | 4 +- .../Implementation/LocationService.cs | 7 +- .../WorkOrderAccountResolver.cs | 53 +++++- .../WorkOrderBoardCreateService.cs | 4 +- .../Interfaces/IWorkOrderAccountResolver.cs | 13 +- .../WorkOrderBoardCreateValidation.cs | 4 +- .../WorkOrderAccountScopeTests.cs | 157 +++++++++++++----- .../WorkOrderAccountTestHelpers.cs | 53 +++++- .../WorkOrderBoardCreateRelationalTests.cs | 4 + .../WorkOrderBoardCreateServiceTests.cs | 45 ++++- .../WorkOrderBoardCreateSyncLockTests.cs | 8 +- .../WorkOrderPhase7CoexistenceTests.cs | 4 +- docs/adr/0001-work-order-single-org-scope.md | 19 ++- 23 files changed, 447 insertions(+), 77 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs index 5f3abe2..e6d97bf 100644 --- a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -46,11 +46,13 @@ public class LocationServiceTests ZipCode = "73301", Phone = "555-1000", ContactEmail = "wh@example.com", - Status = "Active" + Status = "Active", + AccountId = 9 }, CancellationToken.None); var entity = ctx.Locations.Single(); entity.Name.Should().Be("Warehouse"); + entity.AccountId.Should().Be(9); entity.Title.Should().Be("Main WH"); entity.Address1.Should().Be("1 Depot Rd"); entity.City.Should().Be("Austin"); diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index b44f58e..dd91983 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -51,7 +51,8 @@ namespace Api.SeaHavenIndustries.Controllers Phone = l.PhoneNumber, Contact = (string?)null, ContactEmail = l.Email, - Status = l.Status + Status = l.Status, + AccountId = l.AccountId }); var viewModel = new Pagination_DTO @@ -88,7 +89,8 @@ namespace Api.SeaHavenIndustries.Controllers Phone = location.PhoneNumber, Contact = (string?)null, ContactEmail = location.Email, - location.Status + location.Status, + location.AccountId }; return Ok(result); @@ -175,7 +177,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } @@ -191,7 +194,8 @@ namespace Api.SeaHavenIndustries.Controllers ZipCode = model.ZipCode, Phone = model.Phone, ContactEmail = model.ContactEmail, - Status = model.Status + Status = model.Status, + AccountId = model.GetAccountId() }; } } diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index dcd3c03..c458bf8 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -49,7 +49,7 @@ namespace Api.SeaHavenIndustries.DTOs Contact = null, // Not in database schema ContactEmail = entity.Email, Status = entity.Status, - AccountId = null // Not in database schema + AccountId = entity.AccountId?.ToString() }; } @@ -68,7 +68,7 @@ namespace Api.SeaHavenIndustries.DTOs // Contact field doesn't exist in database schema - ignored entity.Email = dto.ContactEmail; entity.Status = dto.Status; - // AccountId field doesn't exist in database schema - ignored + entity.AccountId = dto.GetAccountId(); return entity; } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index fb8b588..a5256dc 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -124,6 +124,13 @@ namespace Data.SeaHavenIndustries builder.Entity() .Property(l => l.ExternalLocationId) .HasMaxLength(450); + builder.Entity() + .HasOne(l => l.Account) + .WithMany() + .HasForeignKey(l => l.AccountId) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() + .HasIndex(l => l.AccountId); builder.Entity() .HasIndex(r => new { r.Source, r.Kind, r.ExternalId }) .IsUnique() diff --git a/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs new file mode 100644 index 0000000..06d81bd --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260826120000_SH221_LocationAccountScope.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH221_LocationAccountScope : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountId", + table: "Locations", + type: "int", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Locations_AccountId", + table: "Locations", + column: "AccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Locations_Accounts_AccountId", + table: "Locations", + column: "AccountId", + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql(@" +;WITH UniqueLocationAccounts AS ( + SELECT + wo.[LocationId] AS [LocationId], + MIN(wo.[AccountId]) AS [AccountId] + FROM [workOrders] wo + WHERE wo.[LocationId] IS NOT NULL + AND wo.[AccountId] IS NOT NULL + GROUP BY wo.[LocationId] + HAVING COUNT(DISTINCT wo.[AccountId]) = 1 +) +UPDATE loc +SET loc.[AccountId] = ula.[AccountId] +FROM [Locations] loc +INNER JOIN UniqueLocationAccounts ula + ON ula.[LocationId] = loc.[Id] +WHERE loc.[AccountId] IS NULL; +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Locations_Accounts_AccountId", + table: "Locations"); + + migrationBuilder.DropIndex( + name: "IX_Locations_AccountId", + table: "Locations"); + + migrationBuilder.DropColumn( + name: "AccountId", + table: "Locations"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index a3051ed..5d86b49 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1486,6 +1486,9 @@ namespace Data.SeaHavenIndustries.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AccountId") + .HasColumnType("int"); + b.Property("Address1") .HasColumnType("nvarchar(max)"); @@ -1553,6 +1556,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("AccountId"); + b.ToTable("Locations"); }); @@ -3210,6 +3215,16 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Account"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => { b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") @@ -3845,6 +3860,8 @@ namespace Data.SeaHavenIndustries.Migrations modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => { + b.Navigation("Account"); + b.Navigation("Templates"); b.Navigation("workOrders"); diff --git a/Data.SeaHavenIndustries/Models/Locations.cs b/Data.SeaHavenIndustries/Models/Locations.cs index 9b43e49..f2d9e2e 100644 --- a/Data.SeaHavenIndustries/Models/Locations.cs +++ b/Data.SeaHavenIndustries/Models/Locations.cs @@ -1,12 +1,13 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using static System.Runtime.InteropServices.JavaScript.JSType; +using System.ComponentModel.DataAnnotations.Schema; namespace Data.SeaHavenIndustries { public class Locations : FullAuditEntity { public int Id { get; set; } + public int? AccountId { get; set; } + [ForeignKey(nameof(AccountId))] + public Accounts? Account { get; set; } public string? Title { get; set; } public string? Name { get; set; } public string? Latitude { get; set; } diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 7ccb6b8..48ade48 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -31,9 +31,23 @@ namespace SeaHaven.DataServices.Implementation public async Task> GetByAccountIdAsync(int accountId) { - // AccountId doesn't exist in database - return all for now - // TODO: Add AccountId column to database if needed - return await _context.Locations.ToListAsync(); + return await _context.Locations + .AsNoTracking() + .Where(l => l.AccountId == accountId) + .ToListAsync(); + } + + public async Task<(bool Exists, int? AccountId)> GetAccountScopeAsync( + int locationId, + CancellationToken cancellationToken = default) + { + var row = await _context.Locations + .AsNoTracking() + .Where(l => l.Id == locationId) + .Select(l => new { l.AccountId }) + .FirstOrDefaultAsync(cancellationToken); + + return row == null ? (false, null) : (true, row.AccountId); } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index a88609a..1bba950 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -19,6 +19,13 @@ namespace SeaHaven.DataServices.Interfaces Task> GetSiteOptionsAsync(string? search = null); + /// + /// Exists is false when the row is missing. AccountId is null when the site has no account. + /// + Task<(bool Exists, int? AccountId)> GetAccountScopeAsync( + int locationId, + CancellationToken cancellationToken = default); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 3a6edcd..fe694f8 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -13,6 +13,7 @@ namespace SeaHaven.Services.DTOs public string? PhoneNumber { get; set; } public string? Email { get; set; } public string? Status { get; set; } + public int? AccountId { get; set; } public DateTime? CreatedDate { get; set; } public string? CreatedBy { get; set; } } @@ -48,6 +49,7 @@ namespace SeaHaven.Services.DTOs public string? Phone { get; set; } public string? ContactEmail { get; set; } public string? Status { get; set; } + public int? AccountId { get; set; } } public class LocationUpdateRequestDTO @@ -61,6 +63,7 @@ namespace SeaHaven.Services.DTOs public string? Phone { get; set; } public string? ContactEmail { get; set; } public string? Status { get; set; } + public int? AccountId { get; set; } } public class SiteOptionDTO diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index d9718ff..593415e 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -21,8 +21,8 @@ namespace SeaHaven.Services.DTOs public bool? IsAddOn { get; set; } public string? SiteCode { get; set; } /// - /// Optional CRM customer name. Required when the caller is org-wide (no account_id) - /// so AccountId can be resolved server-side. + /// Optional display customer name. Ignored when stamping AccountId + /// (board create resolves from Location.AccountId). /// public string? Customer { get; set; } public string? Description { get; set; } diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index da98b66..2a8fbcf 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -94,6 +94,7 @@ namespace SeaHaven.Services.Implementation City = dto.City, State = dto.State, Zip = dto.Zipcode, + AccountId = dto.AccountId, CreatedDate = DateTime.UtcNow, createdby = userId }; @@ -121,6 +122,7 @@ 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)) @@ -187,7 +189,8 @@ namespace SeaHaven.Services.Implementation Zip = request.ZipCode, PhoneNumber = request.Phone, Email = request.ContactEmail, - Status = request.Status + Status = request.Status, + AccountId = request.AccountId }; await _locationDataService.AddAsync(location, cancellationToken); @@ -208,6 +211,7 @@ 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); } @@ -233,6 +237,7 @@ namespace SeaHaven.Services.Implementation PhoneNumber = location.PhoneNumber, Email = location.Email, Status = location.Status, + AccountId = location.AccountId, CreatedDate = location.CreatedDate, CreatedBy = location.createdby }; diff --git a/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs index d6a9419..c09d2f7 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAccountResolver.cs @@ -9,10 +9,12 @@ namespace SeaHaven.Services.Implementation public class WorkOrderAccountResolver : IWorkOrderAccountResolver { private readonly IAccountDataService _accounts; + private readonly ILocationDataService _locations; - public WorkOrderAccountResolver(IAccountDataService accounts) + public WorkOrderAccountResolver(IAccountDataService accounts, ILocationDataService locations) { _accounts = accounts; + _locations = locations; } public int? ResolveAccountFilter(ClaimsPrincipal user) @@ -47,6 +49,55 @@ namespace SeaHaven.Services.Implementation } } + public async Task ResolveForBoardCreateAsync( + ClaimsPrincipal user, + int? locationId, + CancellationToken cancellationToken = default) + { + if (locationId is not int id || id <= 0) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "locationId is required."); + } + + var (exists, locationAccountId) = await _locations.GetAccountScopeAsync(id, cancellationToken); + if (!exists) + { + throw new WorkOrderBoardValidationException( + "NotFound", + "Location was not found."); + } + + if (locationAccountId is not int resolvedAccountId) + { + throw new WorkOrderBoardValidationException( + "AccountUnresolved", + "Work order account could not be resolved from location."); + } + + switch (WorkOrderMediaAuthorization.ResolveMediaScope(user)) + { + case MediaAccountScope.Account account: + if (account.AccountId != resolvedAccountId) + { + throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to create a work order for this location."); + } + + return account.AccountId; + + case MediaAccountScope.OrgWide: + return resolvedAccountId; + + default: + throw new WorkOrderBoardValidationException( + "Forbidden", + "You are not allowed to create work orders without account scope."); + } + } + public Task ResolveForUnauthenticatedCreateAsync( string? customer, CancellationToken cancellationToken = default) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index f644f6e..c584b75 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -46,9 +46,9 @@ namespace SeaHaven.Services.Implementation if (!validationResult.IsValid) throw new ValidationException(validationResult.Errors); - var accountId = await _accountResolver.ResolveForAuthenticatedCreateAsync( + var accountId = await _accountResolver.ResolveForBoardCreateAsync( user, - request.Customer, + request.LocationId, CancellationToken.None); var woNumber = await ResolveWoNumberAsync(request.WoNumber); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs index 8fd209c..6aac4d1 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderAccountResolver.cs @@ -4,7 +4,7 @@ namespace SeaHaven.Services.Interfaces { /// /// Server-derived work-order account scope (SH-221): claims for authenticated callers, - /// unique Accounts.Name ↔ Customer for org-wide / unauthenticated creates. + /// Location.AccountId for board create, unique Accounts.Name ↔ Customer for ingest/webhook. /// public interface IWorkOrderAccountResolver { @@ -17,12 +17,23 @@ namespace SeaHaven.Services.Interfaces /// /// Authenticated create: claim account_id, or org-wide Customer unique match. /// Missing scope → Forbidden. Unresolvable org-wide Customer → AccountUnresolved. + /// Used by legacy AddWorkorder — not board create. /// Task ResolveForAuthenticatedCreateAsync( ClaimsPrincipal user, string? customer, CancellationToken cancellationToken = default); + /// + /// Board create: stamp from JWT account_id or Location.AccountId. Never trusts body customer/accountId. + /// Missing locationId → InvalidValue. Missing location → NotFound. Null Location.AccountId → AccountUnresolved. + /// Scoped location mismatch → Forbidden. + /// + Task ResolveForBoardCreateAsync( + ClaimsPrincipal user, + int? locationId, + CancellationToken cancellationToken = default); + /// /// Ingest/webhook/sync create: unique Customer → Accounts.Id or AccountUnresolved. /// diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 2ed2868..7eb2364 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -62,8 +62,8 @@ namespace SeaHaven.Services.Validation .When(x => !string.IsNullOrEmpty(x.VendorNotes)); RuleFor(x => x.LocationId) - .GreaterThan(0) - .When(x => x.LocationId.HasValue); + .NotNull().WithMessage("locationId is required.") + .GreaterThan(0).WithMessage("locationId is required."); RuleFor(x => x.VendorId) .GreaterThan(0) diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs index 4b75111..85086b2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountScopeTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using FluentValidation; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; @@ -22,54 +23,57 @@ public class WorkOrderAccountScopeTests return new ApplicationDbContext(options); } - [Fact] - public async Task BoardCreate_WithAccountClaim_StampsAccountId() + private static WorkOrderBoardCreateService CreateBoardCreate(ApplicationDbContext context) { - await using var context = CreateContext(); - await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co"); - var resolver = WorkOrderAccountTestHelpers.Resolver(context); 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( + return new WorkOrderBoardCreateService( boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver); + } + [Fact] + public async Task BoardCreate_WithAccountClaim_StampsAccountId() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co"); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7); + + var create = CreateBoardCreate(context); var user = WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"); var row = await create.CreateAsync( new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 11 }, user, "actor-1"); var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); Assert.Equal(7, wo.AccountId); + Assert.Equal(11, wo.LocationId); } [Fact] public async Task BoardCreate_MissingScope_ThrowsForbidden() { await using var context = CreateContext(); - var resolver = WorkOrderAccountTestHelpers.Resolver(context); - 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); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 1); + var create = CreateBoardCreate(context); var ex = await Assert.ThrowsAsync(() => create.CreateAsync( new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 11 }, WorkOrderAccountTestHelpers.MissingScope(), "actor-1")); @@ -79,49 +83,80 @@ public class WorkOrderAccountScopeTests } [Fact] - public async Task BoardCreate_OrgWide_WithUniqueCustomer_StampsAccountId() + public async Task BoardCreate_OrgWide_WithLocationAccount_StampsAccountId() { await using var context = CreateContext(); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Unique Customer"); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 22, accountId: 3, name: "DAL"); - var resolver = WorkOrderAccountTestHelpers.Resolver(context); - 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); - + var create = CreateBoardCreate(context); var row = await create.CreateAsync( new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PO, SiteCode = "DAL", - Customer = "Unique Customer" + LocationId = 22, + Customer = "Ignored Client Customer" }, WorkOrderAccountTestHelpers.OrgWideAdmin(), "admin-1"); var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); Assert.Equal(3, wo.AccountId); - Assert.Equal("Unique Customer", wo.Customer); + Assert.Equal(22, wo.LocationId); + Assert.Equal("Ignored Client Customer", wo.Customer); } [Fact] - public async Task BoardCreate_OrgWide_UnresolvedCustomer_ThrowsAccountUnresolved() + public async Task BoardCreate_OrgWide_LocationWithoutAccount_ThrowsAccountUnresolved() { await using var context = CreateContext(); - var resolver = WorkOrderAccountTestHelpers.Resolver(context); - 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); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 33, accountId: null); + var create = CreateBoardCreate(context); var ex = await Assert.ThrowsAsync(() => + create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 33 + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1")); + + Assert.Equal("AccountUnresolved", ex.Code); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task BoardCreate_OrgWide_MissingLocation_ThrowsNotFound() + { + await using var context = CreateContext(); + var create = CreateBoardCreate(context); + + var ex = await Assert.ThrowsAsync(() => + create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 404 + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1")); + + Assert.Equal("NotFound", ex.Code); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task BoardCreate_MissingLocationId_ThrowsValidation() + { + await using var context = CreateContext(); + var create = CreateBoardCreate(context); + + await Assert.ThrowsAsync(() => create.CreateAsync( new WorkOrderBoardCreateRequestDto { @@ -131,10 +166,56 @@ public class WorkOrderAccountScopeTests WorkOrderAccountTestHelpers.OrgWideAdmin(), "admin-1")); - Assert.Equal("AccountUnresolved", ex.Code); Assert.Empty(context.workOrders); } + [Fact] + public async Task BoardCreate_Scoped_LocationOfOtherAccount_ThrowsForbidden() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 50, accountId: 2); + var create = CreateBoardCreate(context); + + var ex = await Assert.ThrowsAsync(() => + create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 50 + }, + WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"), + "disp-1")); + + Assert.Equal("Forbidden", ex.Code); + Assert.Empty(context.workOrders); + } + + [Fact] + public async Task BoardCreate_OrgWide_ServiceOmitted_Succeeds() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 3, "Org"); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 60, accountId: 3); + var create = CreateBoardCreate(context); + + var row = await create.CreateAsync( + new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + LocationId = 60 + }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + "admin-1"); + + Assert.Null(row.Pm); + var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == row.Id); + Assert.Equal(3, wo.AccountId); + } + [Fact] public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows() { diff --git a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs index 471c0ea..cb7c9f9 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAccountTestHelpers.cs @@ -11,7 +11,9 @@ namespace SeaHavenIndustries.Tests; internal static class WorkOrderAccountTestHelpers { public static IWorkOrderAccountResolver Resolver(ApplicationDbContext context) - => new WorkOrderAccountResolver(new AccountDataService(context)); + => new WorkOrderAccountResolver( + new AccountDataService(context), + new LocationDataService(context)); public static ClaimsPrincipal AccountUser( string userId = "actor-1", @@ -65,4 +67,53 @@ internal static class WorkOrderAccountTestHelpers }); await context.SaveChangesAsync(); } + + public static async Task EnsureLocationAsync( + ApplicationDbContext context, + int id = 1, + int? accountId = 1, + string name = "BK5") + { + if (await context.Locations.AnyAsync(l => l.Id == id)) + return; + + context.Locations.Add(new Locations + { + Id = id, + Name = name, + AccountId = accountId + }); + await context.SaveChangesAsync(); + } + + public static void SeedBoardCreateScope( + ApplicationDbContext context, + int accountId = 1, + int locationId = 1, + string accountName = "Acme Corp") + { + if (!context.Accounts.Local.Any(a => a.Id == accountId) + && !context.Accounts.Any(a => a.Id == accountId)) + { + context.Accounts.Add(new Accounts + { + Id = accountId, + Name = accountName, + IsDeleted = false + }); + } + + if (!context.Locations.Local.Any(l => l.Id == locationId) + && !context.Locations.Any(l => l.Id == locationId)) + { + context.Locations.Add(new Locations + { + Id = locationId, + Name = "BK5", + AccountId = accountId + }); + } + + context.SaveChanges(); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs index 29be2eb..fe1aee0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateRelationalTests.cs @@ -35,6 +35,7 @@ public class WorkOrderBoardCreateRelationalTests }); await context.SaveChangesAsync(); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); @@ -54,6 +55,7 @@ public class WorkOrderBoardCreateRelationalTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, Description = "Relational create" }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId); @@ -95,6 +97,7 @@ public class WorkOrderBoardCreateRelationalTests }); await context.SaveChangesAsync(); await WorkOrderAccountTestHelpers.EnsureAccountAsync(context); + await WorkOrderAccountTestHelpers.EnsureLocationAsync(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); @@ -115,6 +118,7 @@ public class WorkOrderBoardCreateRelationalTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, Description = "Should roll back", PocContactId = 999_999 }, WorkOrderAccountTestHelpers.AccountUser(actorId), actorId)); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 53f4f84..ef58783 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -19,6 +19,7 @@ public class WorkOrderBoardCreateServiceTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); var resolver = WorkOrderAccountTestHelpers.Resolver(context); @@ -38,7 +39,8 @@ public class WorkOrderBoardCreateServiceTests var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal(LifecycleStatus.Incomplete, result.LifecycleStatus); @@ -61,7 +63,8 @@ public class WorkOrderBoardCreateServiceTests var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("SH00002", result.WoNumber); @@ -77,7 +80,8 @@ public class WorkOrderBoardCreateServiceTests { WoNumber = "12345", WorkOrderType = WorkOrderType.PO, - SiteCode = "DAL" + SiteCode = "DAL", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.Equal("00000012345", result.WoNumber); @@ -95,7 +99,8 @@ public class WorkOrderBoardCreateServiceTests { WoNumber = "99999", WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Equal("DuplicateWoNumber", ex.Code); @@ -110,6 +115,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AssignTo = "dispatcher-1", ScheduledDate = new DateTime(2026, 6, 25) }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -127,6 +133,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, ScheduleWeekOnly = true, TargetWeek = new DateOnly(2026, 6, 22) }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -145,6 +152,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, ScheduleWeekOnly = true }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); } @@ -160,6 +168,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, VendorId = 5, ApptDate = new DateTime(2026, 6, 26) }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -177,6 +186,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, Description = "Test WO" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -195,7 +205,8 @@ public class WorkOrderBoardCreateServiceTests var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); var logs = await context.WorkOrderAuditLogs.ToListAsync(); @@ -214,6 +225,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, PrimaryService = "HVAC PM", ExtraServices = new List { "Filter change", "Coil clean", "Filter change" }, ServiceNotes = "Unit on roof" @@ -241,6 +253,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "CHI", + LocationId = 1, Trade = "Legacy Trade", PrimaryService = "Plumbing" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -258,6 +271,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, ExtraServices = new List { "Filter change" } }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); } @@ -271,6 +285,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, PocName = "Jane Site Lead", PocPhone = "+1 555-0100", PocNotes = "Call 30 min before" @@ -294,6 +309,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, TechPhone = "+1 555-0199", VendorNotes = "Gate code 4421" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -317,6 +333,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, VendorId = 55, ApptDate = new DateTime(2026, 7, 18), TechPhone = "+1 555-0199", @@ -343,6 +360,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, Trade = "HVAC", Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -368,6 +386,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, VendorId = 999 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); @@ -384,6 +403,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, ScheduledDate = new DateTime(2026, 6, 24), IsAddOn = false }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -405,6 +425,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, ScheduledDate = new DateTime(2099, 3, 10), IsAddOn = true }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -424,6 +445,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, IsAddOn = true }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -441,6 +463,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AvetaRequired = true }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -460,7 +483,8 @@ public class WorkOrderBoardCreateServiceTests var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); Assert.False(result.AvetaRequired); @@ -480,7 +504,8 @@ public class WorkOrderBoardCreateServiceTests service.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.AddOn, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); Assert.Contains(ex.Errors, e => e.PropertyName == "WorkOrderType"); @@ -495,6 +520,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, PocName = "Primary Lead", AdditionalContacts = new List { @@ -525,6 +551,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List() }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); @@ -540,6 +567,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("", "", null), @@ -565,6 +593,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("Backup Lead", "", null) @@ -584,6 +613,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.PM, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("", "+1 555-0101", null) @@ -599,6 +629,7 @@ public class WorkOrderBoardCreateServiceTests { WorkOrderType = WorkOrderType.Reactive, SiteCode = "BK5", + LocationId = 1, AdditionalContacts = new List { new("Backup Lead", "+1 555-0101", "After hours"), diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs index b1e60f3..62ff194 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateSyncLockTests.cs @@ -18,6 +18,7 @@ public class WorkOrderBoardCreateSyncLockTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); @@ -37,7 +38,8 @@ public class WorkOrderBoardCreateSyncLockTests await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); @@ -65,6 +67,7 @@ public class WorkOrderBoardCreateSyncLockTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); @@ -84,7 +87,8 @@ public class WorkOrderBoardCreateSyncLockTests await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs index 2b90e5d..31ac539 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -65,6 +65,7 @@ public class WorkOrderPhase7CoexistenceTests .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var context = new ApplicationDbContext(options); + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); var boardData = new WorkOrderBoardDataService(context); var mutationData = new WorkOrderBoardMutationDataService(context); @@ -80,7 +81,8 @@ public class WorkOrderPhase7CoexistenceTests await createService.CreateAsync(new WorkOrderBoardCreateRequestDto { WorkOrderType = WorkOrderType.PM, - SiteCode = "BK5" + SiteCode = "BK5", + LocationId = 1 }, WorkOrderAccountTestHelpers.AccountUser("dispatcher-1"), "dispatcher-1"); var wo = await context.workOrders.SingleAsync(); diff --git a/docs/adr/0001-work-order-single-org-scope.md b/docs/adr/0001-work-order-single-org-scope.md index 79b1599..84b8832 100644 --- a/docs/adr/0001-work-order-single-org-scope.md +++ b/docs/adr/0001-work-order-single-org-scope.md @@ -12,8 +12,10 @@ `ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter - **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`): same account filter at service/data entry; authorize before storing blobs -- **Creates** (board, AddWorkorder, ingest, webhook/recon, sync): stamp `AccountId` - from claim or unique `Accounts.Name` ↔ `Customer` match; unresolvable → reject/skip +- **Creates** (board): stamp `AccountId` from JWT `account_id` or `Location.AccountId` + via required `locationId`; body `customer`/`accountId` are not trusted. AddWorkorder, + ingest, webhook/recon, sync still stamp from claim or unique `Accounts.Name` ↔ + `Customer`; unresolvable → reject/skip - Missing/malformed scope → **Forbidden** (absence of claim does not elevate) ## Context (historical) @@ -31,8 +33,13 @@ in review (fail-open) and replaced by the contract below. AccountId). Not inferred from missing `account_id`. 4. **Fail-closed** = no valid account or org-scope claim → Forbidden. 5. **Create stamp**: - - Authenticated + `account_id` → stamp claim (ignore client AccountId). - - Authenticated + `org_scope=all` → unique Customer→Accounts.Name; else + - Board `POST /workorders/board`: required `locationId`. Scoped → stamp + JWT `account_id` only when `Location.AccountId` matches (else Forbidden). + Org-wide → stamp `Location.AccountId`. Missing location → NotFound; + null `Location.AccountId` → AccountUnresolved. Body `customer`/`accountId` + are ignored for the stamp. + - Legacy AddWorkorder: authenticated + `account_id` → stamp claim; + authenticated + `org_scope=all` → unique Customer→Accounts.Name; else `AccountUnresolved`. - Ingest / webhook / sync → same Customer resolution; unresolved create is rejected or skipped (no null AccountId on new rows). @@ -51,8 +58,8 @@ in review (fail-open) and replaced by the contract below. - Dispatcher/Manager/Supervisor/User without AccountId cannot access board, detail, search, list, or media until AccountId is assigned (or they are Admin with `org_scope=all`). -- Locations do not carry AccountId in the EF model; Customer name match is the - unauthenticated resolution path. +- Board create resolves from `Location.AccountId`. Customer name match remains + the ingest/webhook/legacy resolution path. ## Excepted rule From 2e56ec7678017ae4ffd024b0e818b058a7049062 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 10:00:02 -0300 Subject: [PATCH 2/3] 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); + } } From 244a489fb21663d0586b262ac666d18ea7f48471 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 10:20:14 -0300 Subject: [PATCH 3/3] fix(tests): create ephemeral LocalDB database before migration apply --- ...SH221LocationAccountScopeSqlServerTests.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs b/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs index 444575c..917449e 100644 --- a/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs +++ b/SeaHavenIndustries.Tests/SH221LocationAccountScopeSqlServerTests.cs @@ -25,6 +25,8 @@ public class SH221LocationAccountScopeSqlServerTests try { + await CreateDatabaseAsync(dbName); + await using var connection = new SqlConnection(connectionString); await connection.OpenAsync(); @@ -147,21 +149,33 @@ public class SH221LocationAccountScopeSqlServerTests } } + private static async Task CreateDatabaseAsync(string dbName) + { + var quoted = QuoteSqlServerIdentifier(dbName); + await using var connection = new SqlConnection(LocalDbMaster); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE DATABASE {quoted};"; + await command.ExecuteNonQueryAsync(); + } + private static async Task DropDatabaseAsync(string dbName) { try { + var quoted = QuoteSqlServerIdentifier(dbName); 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 + IF DB_ID(@dbName) IS NOT NULL BEGIN - ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE [{dbName}]; + ALTER DATABASE {quoted} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE {quoted}; END """; + command.Parameters.AddWithValue("@dbName", dbName); await command.ExecuteNonQueryAsync(); } catch @@ -169,4 +183,7 @@ public class SH221LocationAccountScopeSqlServerTests // Best-effort cleanup for ephemeral LocalDB databases. } } + + private static string QuoteSqlServerIdentifier(string name) => + "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]"; }