fix(work-orders): keep location account server-owned and forward create cancellation

Stop client writes from changing Locations.AccountId, make the SH-221 migration discoverable, and thread the board-create CancellationToken through lookup and persistence.
This commit is contained in:
Arthur Bassi 2026-08-26 10:00:02 -03:00
parent 61923b2a7d
commit 2e56ec7678
16 changed files with 379 additions and 44 deletions

View file

@ -52,7 +52,7 @@ public class LocationServiceTests
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
entity.AccountId.Should().Be(9);
entity.AccountId.Should().BeNull();
entity.Title.Should().Be("Main WH");
entity.Address1.Should().Be("1 Depot Rd");
entity.City.Should().Be("Austin");
@ -119,6 +119,71 @@ public class LocationServiceTests
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task UpdateLocationFromRequestAsync_PreservesAccountIdWhenOmitted()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Old", "Round Rock");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
City = "Plano"
}, CancellationToken.None);
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task UpdateLocationFromRequestAsync_IgnoresClientAccountIdRelabel()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Owned", "Austin");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "Owned",
AccountId = 99
}, CancellationToken.None);
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task CreateLocationAsync_IgnoresClientAccountId()
{
using var ctx = NewContext();
var created = await NewService(ctx).CreateLocationAsync(new CreateLocationDTO
{
LocationName = "Site",
AccountId = 9
}, "42");
created.AccountId.Should().BeNull();
ctx.Locations.Single().AccountId.Should().BeNull();
}
[Fact]
public async Task UpdateLocationAsync_IgnoresClientAccountIdRelabel()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Owned", "Austin");
existing.AccountId = 4;
await ctx.SaveChangesAsync();
await NewService(ctx).UpdateLocationAsync(existing.Id, new UpdateLocationDTO
{
LocationName = "Owned",
AccountId = 99
}, "42");
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{

View file

@ -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
};
}
}

View file

@ -166,12 +166,14 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
public async Task<IActionResult> CreateBoardWorkOrder(
[FromBody] WorkOrderBoardCreateRequestDto request,
CancellationToken cancellationToken)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, User, actorId);
var row = await _boardCreateService.CreateAsync(request, User, actorId, cancellationToken);
return Ok(row);
}
catch (ValidationException vex)

View file

@ -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
};
}
}

View file

@ -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;
}

View file

@ -1,3 +1,5 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260826120000_SH221_LocationAccountScope")]
public partial class SH221_LocationAccountScope : Migration
{
/// <inheritdoc />

View file

@ -84,7 +84,10 @@ namespace SeaHaven.DataServices.Implementation
return rows.FirstOrDefault();
}
public async Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId)
public async Task<bool> InternalWoNumberExistsAsync(
string normalizedWoNumber,
int excludeWorkOrderId,
CancellationToken cancellationToken = default)
{
return await _context.workOrders
.AsNoTracking()
@ -92,7 +95,7 @@ namespace SeaHaven.DataServices.Implementation
w.Id != excludeWorkOrderId
&& w.istemplate != true
&& (w.IsDeleted != true || w.IsDeleted == null)
&& w.InternalWONumber == normalizedWoNumber);
&& w.InternalWONumber == normalizedWoNumber, cancellationToken);
}
}

View file

@ -12,6 +12,9 @@ namespace SeaHaven.DataServices.Interfaces
int workOrderId,
int? accountId = null,
CancellationToken cancellationToken = default);
Task<bool> InternalWoNumberExistsAsync(string normalizedWoNumber, int excludeWorkOrderId);
Task<bool> InternalWoNumberExistsAsync(
string normalizedWoNumber,
int excludeWorkOrderId,
CancellationToken cancellationToken = default);
}
}

View file

@ -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);
}

View file

@ -40,18 +40,19 @@ namespace SeaHaven.Services.Implementation
public async Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId)
string? actorId,
CancellationToken cancellationToken = default)
{
var validationResult = await _validator.ValidateAsync(request);
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
throw new ValidationException(validationResult.Errors);
var accountId = await _accountResolver.ResolveForBoardCreateAsync(
user,
request.LocationId,
CancellationToken.None);
cancellationToken);
var woNumber = await ResolveWoNumberAsync(request.WoNumber);
var woNumber = await ResolveWoNumberAsync(request.WoNumber, cancellationToken);
var siteCode = request.SiteCode!.Trim();
var primaryService = ResolvePrimaryService(request);
var extraServicesJson = SerializeExtraServices(request.ExtraServices);
@ -69,7 +70,7 @@ namespace SeaHaven.Services.Implementation
if (request.VendorId.HasValue && request.VendorId.Value > 0)
{
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, CancellationToken.None))
if (!await _mutationData.VendorExistsAsync(request.VendorId.Value, cancellationToken))
throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist.");
}
@ -223,7 +224,7 @@ namespace SeaHaven.Services.Implementation
}
await _mutationData.SaveAsync(ct);
}, CancellationToken.None);
}, cancellationToken);
var row = await _boardService.GetBoardRowAsync(workOrder.Id, user);
return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded.");
@ -274,20 +275,20 @@ namespace SeaHaven.Services.Implementation
private static string? TrimOrNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private async Task<string> ResolveWoNumberAsync(string? requested)
private async Task<string> ResolveWoNumberAsync(string? requested, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(requested))
{
if (!WorkOrderNumberNormalizer.TryNormalize(requested, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0))
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, 0, cancellationToken))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
return normalized;
}
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(CancellationToken.None);
var maxId = await _mutationData.GetMaxWorkOrderIdAsync(cancellationToken);
for (var attempt = 0; attempt < 20; attempt++)
{
string candidate;
@ -300,7 +301,7 @@ namespace SeaHaven.Services.Implementation
break;
}
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0))
if (!await _boardDataService.InternalWoNumberExistsAsync(candidate, 0, cancellationToken))
return candidate;
}

View file

@ -113,7 +113,7 @@ namespace SeaHaven.Services.Implementation
}
var auditField = WorkOrderBoardFieldNames.ToAuditFieldName(canonicalField);
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField);
var changes = await ApplyFieldMutationAsync(canonicalField, workOrder, dispatch, request.Value, auditField, ct);
if (resolved is { Created: true, Dispatch: var createdDispatch }
&& canonicalField.Equals(WorkOrderBoardFieldNames.VendorId, StringComparison.OrdinalIgnoreCase))
{
@ -170,11 +170,12 @@ namespace SeaHaven.Services.Implementation
WorkOrder workOrder,
Dispatch? dispatch,
string? value,
string auditField)
string auditField,
CancellationToken cancellationToken)
{
return field switch
{
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField) },
WorkOrderBoardFieldNames.WoNumber => new List<FieldChange> { await ApplyWoNumber(workOrder, value, auditField, cancellationToken) },
WorkOrderBoardFieldNames.WorkOrderType => new List<FieldChange> { ApplyWorkOrderType(workOrder, value, auditField) },
WorkOrderBoardFieldNames.SiteCode => new List<FieldChange> { ApplyStringField(value, auditField, v => workOrder.SiteCode = v, () => workOrder.SiteCode) },
WorkOrderBoardFieldNames.LifecycleStatus => new List<FieldChange> { ApplyLifecycleStatus(workOrder, value, auditField) },
@ -278,12 +279,16 @@ namespace SeaHaven.Services.Implementation
throw new WorkOrderBoardConcurrencyException(currentState);
}
private async Task<FieldChange> ApplyWoNumber(WorkOrder workOrder, string? value, string auditField)
private async Task<FieldChange> ApplyWoNumber(
WorkOrder workOrder,
string? value,
string auditField,
CancellationToken cancellationToken)
{
if (!WorkOrderNumberNormalizer.TryNormalize(value, out var normalized, out var error))
throw new WorkOrderBoardValidationException("InvalidWoNumber", error ?? "Invalid WO number.");
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id))
if (await _boardDataService.InternalWoNumberExistsAsync(normalized, workOrder.Id, cancellationToken))
throw new WorkOrderBoardValidationException("DuplicateWoNumber", "WO number already exists.");
var old = workOrder.InternalWONumber;

View file

@ -8,6 +8,7 @@ namespace SeaHaven.Services.Interfaces
Task<WorkOrderBoardRowDto> CreateAsync(
WorkOrderBoardCreateRequestDto request,
ClaimsPrincipal user,
string? actorId);
string? actorId,
CancellationToken cancellationToken = default);
}
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,172 @@
using System.Reflection;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace SeaHavenIndustries.Tests;
public class SH221LocationAccountScopeSqlServerTests
{
private const string LocalDbMaster =
@"Server=(localdb)\MSSQLLocalDB;Database=master;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=3";
[Fact]
public async Task SH221_LocationAccountScope_ApplyOnSqlServer_WhenLocalDbAvailable()
{
if (!await IsLocalDbAvailableAsync())
return;
var dbName = $"SH221LocationAccount_{Guid.NewGuid():N}";
var connectionString =
$@"Server=(localdb)\MSSQLLocalDB;Database={dbName};Trusted_Connection=True;TrustServerCertificate=True";
try
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using (var createTables = connection.CreateCommand())
{
createTables.CommandText =
"""
CREATE TABLE Accounts (
Id int NOT NULL IDENTITY PRIMARY KEY
);
CREATE TABLE Locations (
Id int NOT NULL IDENTITY PRIMARY KEY
);
CREATE TABLE workOrders (
Id int NOT NULL IDENTITY PRIMARY KEY,
LocationId int NULL,
AccountId int NULL
);
SET IDENTITY_INSERT Accounts ON;
INSERT INTO Accounts (Id) VALUES (1);
SET IDENTITY_INSERT Accounts OFF;
SET IDENTITY_INSERT Locations ON;
INSERT INTO Locations (Id) VALUES (11);
SET IDENTITY_INSERT Locations OFF;
INSERT INTO workOrders (LocationId, AccountId) VALUES (11, 1);
""";
await createTables.ExecuteNonQueryAsync();
}
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(connection)
.Options;
await using var context = new ApplicationDbContext(options);
await ApplyMigrationUpAsync(context, new SH221_LocationAccountScope());
await using (var assertCmd = connection.CreateCommand())
{
assertCmd.CommandText =
"""
SELECT c.name
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(N'Locations')
AND c.name = N'AccountId';
""";
var column = await assertCmd.ExecuteScalarAsync();
Assert.Equal("AccountId", column);
}
await using (var indexCmd = connection.CreateCommand())
{
indexCmd.CommandText =
"""
SELECT name
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'Locations')
AND name = N'IX_Locations_AccountId';
""";
var index = await indexCmd.ExecuteScalarAsync();
Assert.Equal("IX_Locations_AccountId", index);
}
await using (var fkCmd = connection.CreateCommand())
{
fkCmd.CommandText =
"""
SELECT name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID(N'Locations')
AND name = N'FK_Locations_Accounts_AccountId';
""";
var fk = await fkCmd.ExecuteScalarAsync();
Assert.Equal("FK_Locations_Accounts_AccountId", fk);
}
await using (var backfillCmd = connection.CreateCommand())
{
backfillCmd.CommandText = "SELECT AccountId FROM Locations WHERE Id = 11;";
var stamped = await backfillCmd.ExecuteScalarAsync();
Assert.Equal(1, Convert.ToInt32(stamped));
}
}
finally
{
await DropDatabaseAsync(dbName);
}
}
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
{
var builder = new MigrationBuilder(context.Database.ProviderName!);
var up = typeof(Migration).GetMethod("Up", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Migration.Up not found.");
up.Invoke(migration, [builder]);
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
var commands = sqlGenerator.Generate(builder.Operations, model: null);
foreach (var command in commands)
await context.Database.ExecuteSqlRawAsync(command.CommandText);
}
private static async Task<bool> IsLocalDbAvailableAsync()
{
if (!OperatingSystem.IsWindows())
return false;
try
{
await using var connection = new SqlConnection(LocalDbMaster);
await connection.OpenAsync();
return true;
}
catch (SqlException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
private static async Task DropDatabaseAsync(string dbName)
{
try
{
await using var connection = new SqlConnection(LocalDbMaster);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText =
$"""
IF DB_ID(N'{dbName}') IS NOT NULL
BEGIN
ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{dbName}];
END
""";
await command.ExecuteNonQueryAsync();
}
catch
{
// Best-effort cleanup for ephemeral LocalDB databases.
}
}
}

View file

@ -4,6 +4,7 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -216,6 +217,38 @@ public class WorkOrderAccountScopeTests
Assert.Equal(3, wo.AccountId);
}
[Fact]
public async Task BoardCreate_ForwardsCancellationToken_ToLocationAccountLookup()
{
await using var context = CreateContext();
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 7, "Scoped Co");
await WorkOrderAccountTestHelpers.EnsureLocationAsync(context, id: 11, accountId: 7);
var locations = new RecordingLocationDataService(new LocationDataService(context));
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), locations);
var boardData = new WorkOrderBoardDataService(context);
var mutationData = new WorkOrderBoardMutationDataService(context);
var boardService = new WorkOrderBoardService(boardData, resolver);
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
var create = new WorkOrderBoardCreateService(
boardData, mutationData, boardService, audit, new WorkOrderBoardCreateValidation(), resolver);
using var cts = new CancellationTokenSource();
await create.CreateAsync(
new WorkOrderBoardCreateRequestDto
{
WorkOrderType = WorkOrderType.PM,
SiteCode = "BK5",
LocationId = 11
},
WorkOrderAccountTestHelpers.AccountUser("actor-1", 7, "Dispatcher"),
"actor-1",
cts.Token);
Assert.Equal(cts.Token, locations.LastScopeToken);
}
[Fact]
public async Task Board_ScopedUser_HidesOtherAccountAndNullAccountRows()
{
@ -763,4 +796,54 @@ public class WorkOrderAccountScopeTests
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", missingEx.Code);
}
private sealed class RecordingLocationDataService : ILocationDataService
{
private readonly ILocationDataService _inner;
public RecordingLocationDataService(ILocationDataService inner)
{
_inner = inner;
}
public CancellationToken LastScopeToken { get; private set; }
public Task<Locations?> GetByIdAsync(int id) => _inner.GetByIdAsync(id);
public Task<Locations?> GetByIdWithDetailsAsync(int id) => _inner.GetByIdWithDetailsAsync(id);
public Task<IEnumerable<Locations>> GetAllAsync() => _inner.GetAllAsync();
public Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId) => _inner.GetByAccountIdAsync(accountId);
public Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null)
=> _inner.GetPagedAsync(page, pageSize, search);
public Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null)
=> _inner.GetAddressbookPagedAsync(page, pageSize, search);
public Task<IReadOnlyList<SiteOptionRow>> GetSiteOptionsAsync(string? search = null)
=> _inner.GetSiteOptionsAsync(search);
public Task<(bool Exists, int? AccountId)> GetAccountScopeAsync(
int locationId,
CancellationToken cancellationToken = default)
{
LastScopeToken = cancellationToken;
return _inner.GetAccountScopeAsync(locationId, cancellationToken);
}
public Task<Locations> AddAsync(Locations location) => _inner.AddAsync(location);
public Task UpdateAsync(Locations location) => _inner.UpdateAsync(location);
public Task DeleteAsync(int id) => _inner.DeleteAsync(id);
public Task<bool> ExistsAsync(int id) => _inner.ExistsAsync(id);
public Task<int> CountAsync() => _inner.CountAsync();
public Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
int page, int pageSize, string? search, CancellationToken cancellationToken)
=> _inner.GetListPagedAsync(page, pageSize, search, cancellationToken);
public Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
=> _inner.AddAsync(location, cancellationToken);
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
=> _inner.UpdateAsync(location, cancellationToken);
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.DeleteByIdAsync(id, cancellationToken);
}
}

View file

@ -32,4 +32,17 @@ public class WorkOrderMigrationDiscoveryTests
Assert.Contains("20260824150000_SH117_AvetaRequired", migrations);
}
[Fact]
public void SH221_LocationAccountScope_is_discoverable_by_ef_runtime()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
using var context = new ApplicationDbContext(options);
var migrations = context.Database.GetMigrations().ToList();
Assert.Contains("20260826120000_SH221_LocationAccountScope", migrations);
}
}