mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # SeaHaven.DataServices/Implementation/LocationDataService.cs
This commit is contained in:
commit
0088cffd47
31 changed files with 5684 additions and 317 deletions
|
|
@ -31,7 +31,9 @@ public class LocationControllerSitesTests
|
|||
dataService,
|
||||
new AccountDataService(ctx),
|
||||
Mock.Of<ICreateLocationValidation>(),
|
||||
Mock.Of<IUpdateLocationValidation>());
|
||||
Mock.Of<IUpdateLocationValidation>(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -286,4 +286,80 @@ public class LocationControllerTests
|
|||
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
|
||||
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
|
||||
|
||||
var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None);
|
||||
|
||||
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
||||
Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode");
|
||||
Prop(conflict.Value!, "Message").Should().Be("This site code already exists.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditLocation_DuplicateSiteCode_Returns409()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
|
||||
|
||||
var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<ConflictObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException(
|
||||
SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage));
|
||||
|
||||
var result = await NewController(service).DeleteLocation(4, CancellationToken.None);
|
||||
|
||||
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
|
||||
forbidden.StatusCode.Should().Be(403);
|
||||
forbidden.Value.Should().BeOfType<Response>().Which.Message.Should().Be("You are not allowed to delete sites.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
|
||||
|
||||
var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None))
|
||||
.Should().BeOfType<OkObjectResult>().Subject;
|
||||
ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
|
||||
|
||||
(await NewController(service).GetOpenWorkOrders(5, CancellationToken.None))
|
||||
.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService()
|
||||
{
|
||||
var service = new Mock<ILocationService>();
|
||||
SiteContactInfoRequestDTO? seen = null;
|
||||
service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny<SiteContactInfoRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<int, SiteContactInfoRequestDTO, ClaimsPrincipal, CancellationToken>((_, request, _, _) => seen = request)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO
|
||||
{
|
||||
Contacts = new List<SiteContactInput_DTO> { new() { Id = 7, Name = "Main", Phone = "555-0100" } },
|
||||
Notes = "Gate 4"
|
||||
}, CancellationToken.None);
|
||||
|
||||
result.Should().BeOfType<OkObjectResult>();
|
||||
seen!.Notes.Should().Be("Gate 4");
|
||||
seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
|
||||
public class LocationServiceTests
|
||||
{
|
||||
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
|
||||
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
|
||||
{
|
||||
if (request.AccountId == null)
|
||||
{
|
||||
if (!ctx.Accounts.Any(a => a.Id == 7))
|
||||
{
|
||||
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
request.AccountId = 7;
|
||||
}
|
||||
|
||||
request.Address ??= "1 Depot Rd";
|
||||
request.City ??= "Dallas";
|
||||
request.State ??= "TX";
|
||||
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
|
||||
return request;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
|
|
@ -28,7 +49,9 @@ public class LocationServiceTests
|
|||
new LocationDataService(ctx),
|
||||
new AccountDataService(ctx),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer")
|
||||
{
|
||||
|
|
@ -83,7 +106,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 9);
|
||||
var service = NewService(ctx);
|
||||
|
||||
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Title = "Main WH",
|
||||
|
|
@ -95,7 +118,7 @@ public class LocationServiceTests
|
|||
ContactEmail = "wh@example.com",
|
||||
Status = "Active",
|
||||
AccountId = 9
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var entity = ctx.Locations.Single();
|
||||
entity.Name.Should().Be("Warehouse");
|
||||
|
|
@ -211,7 +234,9 @@ public class LocationServiceTests
|
|||
dataService,
|
||||
Mock.Of<IAccountDataService>(),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
||||
|
|
@ -237,14 +262,14 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "New",
|
||||
Name = "Old",
|
||||
Address = "9 New St",
|
||||
City = "Plano",
|
||||
Status = "Inactive"
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("New");
|
||||
row.Name.Should().Be("Old", "the site code is immutable");
|
||||
row.Address1.Should().Be("9 New St");
|
||||
row.City.Should().Be("Plano");
|
||||
row.Status.Should().Be("Inactive");
|
||||
|
|
@ -263,7 +288,7 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||
{
|
||||
Name = "New",
|
||||
Name = "Old",
|
||||
City = "Plano"
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
|
|
@ -301,12 +326,12 @@ public class LocationServiceTests
|
|||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
existing.Id,
|
||||
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
|
||||
new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" },
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
var row = ctx.Locations.Single();
|
||||
row.Name.Should().Be("Renamed");
|
||||
row.Name.Should().Be("Owned");
|
||||
row.City.Should().Be("Austin");
|
||||
row.AccountId.Should().Be(4);
|
||||
}
|
||||
|
|
@ -386,7 +411,7 @@ public class LocationServiceTests
|
|||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }),
|
||||
OrgWideAdmin(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -402,7 +427,7 @@ public class LocationServiceTests
|
|||
ctx.SaveChanges();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }),
|
||||
OrgWideAdmin(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -418,7 +443,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 99);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }),
|
||||
AccountUser(4),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -453,7 +478,7 @@ public class LocationServiceTests
|
|||
SeedAccount(ctx, 9);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
|
||||
MissingScope(),
|
||||
CancellationToken.None);
|
||||
|
||||
|
|
@ -476,12 +501,14 @@ public class LocationServiceTests
|
|||
new LocationDataService(ctx),
|
||||
accounts.Object,
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await service.CreateLocationFromRequestAsync(
|
||||
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
|
||||
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
|
||||
OrgWideAdmin(),
|
||||
cts.Token);
|
||||
|
||||
|
|
@ -520,20 +547,6 @@ public class LocationServiceTests
|
|||
ctx.Locations.Single().AccountId.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
|
||||
|
||||
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
||||
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
||||
|
||||
removed.Should().BeTrue();
|
||||
again.Should().BeFalse();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,27 @@ namespace Api.SeaHavenIndustries.Tests;
|
|||
|
||||
public class LocationSiteContactsTests
|
||||
{
|
||||
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
|
||||
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
|
||||
{
|
||||
if (request.AccountId == null)
|
||||
{
|
||||
if (!ctx.Accounts.Any(a => a.Id == 7))
|
||||
{
|
||||
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
request.AccountId = 7;
|
||||
}
|
||||
|
||||
request.Address ??= "1 Depot Rd";
|
||||
request.City ??= "Dallas";
|
||||
request.State ??= "TX";
|
||||
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
|
||||
return request;
|
||||
}
|
||||
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
|
|
@ -35,7 +56,9 @@ public class LocationSiteContactsTests
|
|||
new LocationDataService(ctx),
|
||||
new AccountDataService(ctx),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
private static ClaimsPrincipal OrgWideAdmin()
|
||||
{
|
||||
|
|
@ -51,27 +74,28 @@ public class LocationSiteContactsTests
|
|||
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
|
||||
{
|
||||
var service = NewService(ctx);
|
||||
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Depot",
|
||||
Phone = "555-9000",
|
||||
AccountId = null,
|
||||
Contacts = new List<SiteContactRequestDTO>
|
||||
{
|
||||
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
|
||||
new() { Name = "Bob Dillon", Phone = "555-0200"}
|
||||
}
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
return ctx.Locations.Include(l => l.Contacts).Single();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
|
||||
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
AccountId = 7,
|
||||
|
|
@ -80,10 +104,10 @@ public class LocationSiteContactsTests
|
|||
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
|
||||
new() { Name = "Bob Dillon", Phone = "555-0200" }
|
||||
}
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var location = ctx.Locations.Include(l => l.Contacts).Single();
|
||||
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
|
||||
location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts");
|
||||
|
||||
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
|
||||
contacts.Should().HaveCount(2);
|
||||
|
|
@ -104,11 +128,11 @@ public class LocationSiteContactsTests
|
|||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Contacts = new List<SiteContactRequestDTO>()
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
|
||||
|
|
@ -127,11 +151,11 @@ public class LocationSiteContactsTests
|
|||
using var ctx = NewContext();
|
||||
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Contacts = contacts
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
if (expectedError == null)
|
||||
{
|
||||
|
|
@ -148,14 +172,14 @@ public class LocationSiteContactsTests
|
|||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Contacts = new List<SiteContactRequestDTO>
|
||||
{
|
||||
new() { Name = new string('x', 101), Phone = new string('5', 21) }
|
||||
}
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
|
||||
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
|
||||
|
|
@ -170,11 +194,11 @@ public class LocationSiteContactsTests
|
|||
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
|
||||
.ToList();
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
|
||||
{
|
||||
Name = "Warehouse",
|
||||
Contacts = contacts
|
||||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
}), OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
|
||||
|
|
@ -220,7 +244,7 @@ public class LocationSiteContactsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
|
||||
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||
|
|
@ -239,7 +263,7 @@ public class LocationSiteContactsTests
|
|||
}, OrgWideAdmin(), CancellationToken.None);
|
||||
|
||||
var location = ctx.Locations.Include(l => l.Contacts).Single();
|
||||
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
|
||||
location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact");
|
||||
|
||||
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
|
||||
active.Should().HaveCount(3);
|
||||
|
|
@ -304,7 +328,7 @@ public class LocationSiteContactsTests
|
|||
after.Should().HaveCount(snapshot.Count);
|
||||
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
|
||||
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
|
||||
.Should().Be("555-0100", "the rejected update must not persist any change");
|
||||
.Should().Be("555-9000", "the rejected update must not persist any change");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -448,7 +472,9 @@ public class LocationSiteContactsTests
|
|||
data.Object,
|
||||
Mock.Of<IAccountDataService>(),
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation());
|
||||
new UpdateLocationValidation(),
|
||||
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
|
||||
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
|
||||
|
||||
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
|
||||
|
||||
|
|
@ -489,67 +515,6 @@ public class LocationSiteContactsTests
|
|||
rows.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
|
||||
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
||||
|
||||
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
|
||||
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
|
||||
retained.Should().HaveCount(2);
|
||||
retained.Should().OnlyContain(c => c.IsDeleted == true);
|
||||
retained.Should().OnlyContain(c => c.LocationId == null);
|
||||
retained.Should().OnlyContain(c => c.DeletionTime != null);
|
||||
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
|
||||
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
|
||||
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
|
||||
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
|
||||
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var seeded = await SeedLocationWithContactsAsync(ctx);
|
||||
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
|
||||
bob.IsDeleted = true;
|
||||
bob.DeleterUserId = "admin-1";
|
||||
bob.DeletionTime = new DateTime(2026, 1, 1);
|
||||
await ctx.SaveChangesAsync();
|
||||
var bobStamp = bob.DeletionTime;
|
||||
|
||||
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
|
||||
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
|
||||
retained.IsDeleted.Should().BeTrue();
|
||||
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
|
||||
retained.DeletionTime.Should().Be(bobStamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
await SeedLocationWithContactsAsync(ctx);
|
||||
|
||||
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
|
||||
|
||||
deleted.Should().BeFalse();
|
||||
ctx.Locations.Should().HaveCount(1);
|
||||
ctx.Contacts.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
|
||||
{
|
||||
public void UpExposed(MigrationBuilder builder) => Up(builder);
|
||||
|
|
@ -608,7 +573,7 @@ public class LocationSiteContactsTests
|
|||
using var json = JsonSerializer.SerializeToDocument(ok.Value);
|
||||
var root = json.RootElement;
|
||||
|
||||
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
|
||||
root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts");
|
||||
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
|
||||
var contacts = root.GetProperty("Contacts");
|
||||
contacts.GetArrayLength().Should().Be(2);
|
||||
|
|
@ -636,7 +601,7 @@ public class LocationSiteContactsTests
|
|||
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
|
||||
|
||||
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-0100");
|
||||
row.GetProperty("Phone").GetString().Should().Be("555-9000");
|
||||
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal file
503
Api.SeaHavenIndustries.Tests/SiteRegistryServiceTests.cs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Site registry rules: tenant-scoped unique site codes, immutable codes,
|
||||
/// permission-gated tombstone delete, open work order lookup and the
|
||||
/// contact/notes write used by the work-order Site dialog.
|
||||
/// </summary>
|
||||
public class SiteRegistryServiceTests
|
||||
{
|
||||
private static ApplicationDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new ApplicationDbContext(options);
|
||||
}
|
||||
|
||||
private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") =>
|
||||
NewService(new LocationDataService(ctx), new AccountDataService(ctx), role);
|
||||
|
||||
private static LocationService NewService(
|
||||
ILocationDataService data,
|
||||
IAccountDataService accounts,
|
||||
string? role = "Admin")
|
||||
{
|
||||
var permissions = new Mock<ITeamPermissionOverrideDataService>();
|
||||
permissions
|
||||
.Setup(p => p.GetUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string userId, CancellationToken _) => role == null
|
||||
? null
|
||||
: new TeamPermissionUserData { UserId = userId, RoleName = role });
|
||||
|
||||
return new LocationService(
|
||||
data,
|
||||
accounts,
|
||||
new CreateLocationValidation(),
|
||||
new UpdateLocationValidation(),
|
||||
permissions.Object,
|
||||
new TeamPermissionPolicy());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
|
||||
|
||||
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") =>
|
||||
Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()));
|
||||
|
||||
private static ClaimsPrincipal Principal(string role, Claim scope) =>
|
||||
new(new ClaimsIdentity(new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, "actor-1"),
|
||||
new(ClaimTypes.Role, role),
|
||||
scope
|
||||
}, "test"));
|
||||
|
||||
private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false });
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false)
|
||||
{
|
||||
var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null };
|
||||
ctx.Locations.Add(site);
|
||||
ctx.SaveChanges();
|
||||
return site;
|
||||
}
|
||||
|
||||
private static WorkOrder Wo(
|
||||
int id,
|
||||
int? locationId,
|
||||
LifecycleStatus? status = LifecycleStatus.Incomplete,
|
||||
int? accountId = 1,
|
||||
string? siteCode = null,
|
||||
string? legacyStatus = null,
|
||||
bool deleted = false) => new()
|
||||
{
|
||||
Id = id,
|
||||
LocationId = locationId,
|
||||
LifecycleStatus = status,
|
||||
LegacyStatus = legacyStatus,
|
||||
AccountId = accountId,
|
||||
SiteCode = siteCode,
|
||||
IsDeleted = deleted ? true : null
|
||||
};
|
||||
|
||||
private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new()
|
||||
{
|
||||
Name = code,
|
||||
AccountId = accountId,
|
||||
Address = "1 Depot Rd",
|
||||
City = "Dallas",
|
||||
State = "TX",
|
||||
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
SeedSite(ctx, "BK5", 1);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<SiteCodeConflictException>();
|
||||
ctx.Locations.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SameSiteCodeForAnotherClient_IsAllowed()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1, 2);
|
||||
SeedSite(ctx, "BK5", 1);
|
||||
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None);
|
||||
|
||||
ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SiteCodeOfADeletedSite_CanBeReused()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
SeedSite(ctx, "BK5", 1, deleted: true);
|
||||
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None);
|
||||
|
||||
ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_BlankSiteCode_IsAValidationError()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
var request = CreateRequest(" DFW8 ", 1);
|
||||
request.Phone = "555-9000";
|
||||
request.Notes = " Gate code 4411 ";
|
||||
|
||||
await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
|
||||
|
||||
var site = ctx.Locations.Single();
|
||||
site.Name.Should().Be("DFW8");
|
||||
site.PhoneNumber.Should().Be("555-9000");
|
||||
site.Notes.Should().Be("Gate code 4411");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ChangingAnExistingSiteCode_IsRejected()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", null);
|
||||
|
||||
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed.");
|
||||
ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", null);
|
||||
site.Notes = "Dock 3";
|
||||
site.Status = "Active";
|
||||
ctx.SaveChanges();
|
||||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None);
|
||||
|
||||
var saved = ctx.Locations.AsNoTracking().Single();
|
||||
saved.Name.Should().Be("BK5");
|
||||
saved.City.Should().Be("Memphis");
|
||||
saved.Notes.Should().Be("Dock 3");
|
||||
saved.Status.Should().Be("Active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
SeedSite(ctx, "BK5", 1);
|
||||
var legacy = SeedSite(ctx, "", 1);
|
||||
|
||||
var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None);
|
||||
await duplicate.Should().ThrowAsync<SiteCodeConflictException>();
|
||||
|
||||
await NewService(ctx).UpdateLocationFromRequestAsync(
|
||||
legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None);
|
||||
ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Scheduler")]
|
||||
public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
var site = SeedSite(ctx, "BK5", 1);
|
||||
ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" });
|
||||
ctx.workOrders.Add(Wo(10, site.Id));
|
||||
ctx.SaveChanges();
|
||||
|
||||
var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None);
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
var tombstone = ctx.Locations.AsNoTracking().Single();
|
||||
tombstone.IsDeleted.Should().BeTrue();
|
||||
tombstone.DeleterUserId.Should().Be("actor-1");
|
||||
ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference");
|
||||
ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders");
|
||||
|
||||
var data = new LocationDataService(ctx);
|
||||
(await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull();
|
||||
(await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty();
|
||||
(await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0);
|
||||
(await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders");
|
||||
(await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dispatcher")]
|
||||
[InlineData(null)]
|
||||
public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", null);
|
||||
|
||||
var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<SiteForbiddenException>();
|
||||
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", 2);
|
||||
|
||||
var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", 1);
|
||||
var other = SeedSite(ctx, "MEM1", 1);
|
||||
ctx.workOrders.AddRange(
|
||||
Wo(1, site.Id),
|
||||
Wo(2, site.Id, LifecycleStatus.Scheduled),
|
||||
Wo(3, site.Id, LifecycleStatus.Completed),
|
||||
Wo(4, site.Id, LifecycleStatus.Canceled),
|
||||
Wo(5, site.Id, deleted: true),
|
||||
Wo(6, site.Id, status: null, legacyStatus: "Completed"),
|
||||
Wo(7, site.Id, status: null, legacyStatus: "Open"),
|
||||
Wo(8, null, siteCode: "bk5"),
|
||||
Wo(9, null, siteCode: "BK5", accountId: 2),
|
||||
Wo(10, other.Id));
|
||||
ctx.SaveChanges();
|
||||
|
||||
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
|
||||
|
||||
result!.WorkOrderIds.Should().Equal(1, 2, 7, 8);
|
||||
result.Count.Should().Be(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", 1);
|
||||
ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2));
|
||||
ctx.SaveChanges();
|
||||
|
||||
var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None);
|
||||
own!.WorkOrderIds.Should().Equal(1);
|
||||
|
||||
var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None);
|
||||
await foreign.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrders_ReturnFullCountButCapIds()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", 1);
|
||||
ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id)));
|
||||
ctx.SaveChanges();
|
||||
|
||||
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
|
||||
|
||||
result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5);
|
||||
result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var deleted = SeedSite(ctx, "BK5", 1, deleted: true);
|
||||
|
||||
(await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull();
|
||||
(await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var site = SeedSite(ctx, "BK5", null);
|
||||
var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 };
|
||||
ctx.Contacts.Add(main);
|
||||
ctx.SaveChanges();
|
||||
|
||||
await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO
|
||||
{
|
||||
Contacts = new List<SiteContactRequestDTO>
|
||||
{
|
||||
new() { Id = main.Id, Name = "New Main", Phone = "555-0199" },
|
||||
new() { Name = "Night Shift", Phone = "555-0200" }
|
||||
},
|
||||
Notes = " Call ahead "
|
||||
}, OrgWide("Dispatcher"), CancellationToken.None);
|
||||
|
||||
var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single();
|
||||
saved.Notes.Should().Be("Call ahead");
|
||||
saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder)
|
||||
.Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber))
|
||||
.Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var foreign = SeedSite(ctx, "BK5", 2);
|
||||
var deleted = SeedSite(ctx, "MEM1", 1, deleted: true);
|
||||
var request = new SiteContactInfoRequestDTO
|
||||
{
|
||||
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
|
||||
};
|
||||
|
||||
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None)))
|
||||
.Should().ThrowAsync<UnauthorizedAccessException>();
|
||||
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None)))
|
||||
.Should().ThrowAsync<KeyNotFoundException>();
|
||||
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(
|
||||
foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None)))
|
||||
.Should().ThrowAsync<FluentValidation.ValidationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewSiteOperations_ForwardTheCallersCancellationToken()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List<Contacts>() };
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site);
|
||||
data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site);
|
||||
data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token))
|
||||
.ReturnsAsync((1, new[] { 7 }));
|
||||
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
|
||||
|
||||
await service.GetOpenWorkOrdersAsync(5, OrgWide(), token);
|
||||
await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO
|
||||
{
|
||||
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
|
||||
}, OrgWide(), token);
|
||||
await service.DeleteLocationByIdAsync(5, OrgWide(), token);
|
||||
|
||||
data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once);
|
||||
data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var token = cts.Token;
|
||||
var data = new Mock<ILocationDataService>();
|
||||
data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true);
|
||||
var accounts = new Mock<IAccountDataService>();
|
||||
accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true);
|
||||
var service = NewService(data.Object, accounts.Object);
|
||||
|
||||
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token);
|
||||
|
||||
await act.Should().ThrowAsync<SiteCodeConflictException>();
|
||||
data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone()
|
||||
{
|
||||
var workOrder = new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List<Contacts>() }
|
||||
};
|
||||
|
||||
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
|
||||
|
||||
using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!);
|
||||
frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411");
|
||||
}
|
||||
|
||||
public static TheoryData<string, Action<LocationCreateRequestDTO>, string> MissingSiteFields => new()
|
||||
{
|
||||
{ "no client", r => r.AccountId = null, "Client is required." },
|
||||
{ "no street address", r => r.Address = " ", "Street Address is required." },
|
||||
{ "no city", r => r.City = null, "City is required." },
|
||||
{ "no state", r => r.State = "", "State is required." },
|
||||
{ "no contacts list", r => r.Contacts = null, "At least one contact is required." },
|
||||
{ "empty contacts list", r => r.Contacts = new List<SiteContactRequestDTO>(), "At least one contact is required." },
|
||||
{ "contact without phone", r => r.Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = " " } }, "Contact phone is required." }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MissingSiteFields))]
|
||||
public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing(
|
||||
string _,
|
||||
Action<LocationCreateRequestDTO> strip,
|
||||
string expectedMessage)
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
var request = CreateRequest("BK9", 1);
|
||||
strip(request);
|
||||
|
||||
var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
|
||||
|
||||
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
|
||||
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage);
|
||||
ctx.Locations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesTheSiteFromEveryLegacyRead()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAccounts(ctx, 1);
|
||||
var deleted = SeedSite(ctx, "BK5", 1);
|
||||
var kept = SeedSite(ctx, "BK6", 1);
|
||||
var service = NewService(ctx);
|
||||
|
||||
(await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
(await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull();
|
||||
(await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull();
|
||||
(await service.LocationExistsAsync(deleted.Id)).Should().BeFalse();
|
||||
(await service.GetTotalLocationCountAsync()).Should().Be(1);
|
||||
(await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id);
|
||||
(await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id);
|
||||
(await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id);
|
||||
(await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1);
|
||||
(await new VendorOperationsDataService(ctx, Mock.Of<IDispatchDataService>()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using Api.SeaHavenIndustries.Controllers;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public sealed class TeamMemberPermissionsEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array()
|
||||
{
|
||||
var data = new Mock<ITeamPermissionOverrideDataService>();
|
||||
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TeamPermissionUserData
|
||||
{
|
||||
UserId = "u1",
|
||||
RoleName = "Dispatcher",
|
||||
Overrides = new Dictionary<string, UserPermissionState>
|
||||
{
|
||||
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
|
||||
}
|
||||
});
|
||||
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(await controller.GetMyPermissions(CancellationToken.None));
|
||||
|
||||
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var keys = document.RootElement.GetProperty("permissions").EnumerateArray()
|
||||
.Select(element => element.GetString()).ToList();
|
||||
Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys);
|
||||
Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthenticated_caller_gets_401_without_data_access()
|
||||
{
|
||||
var data = new Mock<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
|
||||
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
|
||||
var result = Assert.IsType<UnauthorizedResult>(await controller.GetMyPermissions(CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Route_is_get_me_permissions_and_requires_authentication()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var descriptor = provider
|
||||
.GetRequiredService<IActionDescriptorCollectionProvider>()
|
||||
.ActionDescriptors.Items
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
|
||||
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
|
||||
|
||||
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
|
||||
.SelectMany(c => c.HttpMethods);
|
||||
Assert.Equal(new[] { "GET" }, methods);
|
||||
Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template);
|
||||
Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true));
|
||||
Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true));
|
||||
}
|
||||
|
||||
private static TeamMemberController Controller(
|
||||
ITeamPermissionOverrideDataService data,
|
||||
ClaimsPrincipal user) =>
|
||||
new(Mock.Of<ITeamMemberService>(), new TeamPermissionService(data, new TeamPermissionPolicy()))
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = user }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Controllers
|
||||
|
|
@ -105,6 +106,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = location.Email,
|
||||
location.Status,
|
||||
location.AccountId,
|
||||
ClientName = location.AccountName,
|
||||
location.AccountName,
|
||||
location.Notes,
|
||||
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
|
||||
};
|
||||
|
||||
|
|
@ -119,6 +123,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
|
||||
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
|
||||
}
|
||||
catch (SiteCodeConflictException)
|
||||
{
|
||||
return SiteCodeConflict();
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
|
|
@ -142,6 +150,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken);
|
||||
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
|
||||
}
|
||||
catch (SiteCodeConflictException)
|
||||
{
|
||||
return SiteCodeConflict();
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
|
|
@ -161,17 +173,73 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/contact-info")]
|
||||
public async Task<IActionResult> UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _locationService.UpdateSiteContactInfoAsync(
|
||||
id,
|
||||
new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes },
|
||||
User,
|
||||
cancellationToken);
|
||||
return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" });
|
||||
}
|
||||
catch (ValidationException vex)
|
||||
{
|
||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}/open-work-orders")]
|
||||
public async Task<IActionResult> GetOpenWorkOrders(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken);
|
||||
if (result == null)
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
|
||||
var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken);
|
||||
if (!found)
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
|
||||
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
|
||||
}
|
||||
catch (SiteForbiddenException forbidden)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||
|
|
@ -188,6 +256,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
return await DeleteLocation(id, cancellationToken);
|
||||
}
|
||||
|
||||
private ObjectResult SiteCodeConflict() =>
|
||||
Conflict(new
|
||||
{
|
||||
Status = "Conflict",
|
||||
Message = SiteCodeConflictException.PublicMessage,
|
||||
Code = SiteCodeConflictException.ErrorCode
|
||||
});
|
||||
|
||||
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
|
||||
{
|
||||
return new LocationCreateRequestDTO
|
||||
|
|
@ -202,6 +278,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId(),
|
||||
Notes = model.Notes,
|
||||
Contacts = MapSiteContacts(model.Contacts)
|
||||
};
|
||||
}
|
||||
|
|
@ -220,6 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
ContactEmail = model.ContactEmail,
|
||||
Status = model.Status,
|
||||
AccountId = model.GetAccountId(),
|
||||
Notes = model.Notes,
|
||||
Contacts = MapSiteContacts(model.Contacts)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,21 @@ namespace Api.SeaHavenIndustries.Controllers;
|
|||
public sealed class TeamMemberController : ControllerBase
|
||||
{
|
||||
private readonly ITeamMemberService _teamMemberService;
|
||||
private readonly ITeamPermissionService _permissionService;
|
||||
|
||||
public TeamMemberController(ITeamMemberService teamMemberService)
|
||||
public TeamMemberController(
|
||||
ITeamMemberService teamMemberService,
|
||||
ITeamPermissionService permissionService)
|
||||
{
|
||||
_teamMemberService = teamMemberService;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
[HttpGet("me/permissions")]
|
||||
public async Task<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
|
||||
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ public sealed class TeamPermissionController : ControllerBase
|
|||
return result.Status switch
|
||||
{
|
||||
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
|
||||
TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(),
|
||||
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
|
||||
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
|
||||
new Response { Status = "Error", Message = "User not found." }),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? ContactEmail { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? AccountId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<SiteContactInput_DTO>? Contacts { get; set; }
|
||||
|
||||
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? Phone { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Contacts and notes edited from the work-order Site dialog.</summary>
|
||||
public class SiteContactInfoInput_DTO
|
||||
{
|
||||
public List<SiteContactInput_DTO>? Contacts { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class Location_DTO
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
|
|
@ -24,6 +31,7 @@ namespace Api.SeaHavenIndustries.DTOs
|
|||
public string? ContactEmail { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? AccountId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<SiteContactInput_DTO>? Contacts { get; set; }
|
||||
|
||||
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);
|
||||
|
|
|
|||
4216
Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs
generated
Normal file
4216
Data.SeaHavenIndustries/Migrations/20260925141509_AddLocationNotes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLocationNotes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "Locations",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Notes",
|
||||
table: "Locations");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1616,6 +1616,9 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ namespace Data.SeaHavenIndustries
|
|||
public string? ExternalSource { get; set; }
|
||||
public string? ExternalLocationId { get; set; }
|
||||
|
||||
/// <summary>Free-text site notes, edited from the work-order Site dialog.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public ICollection<Template>? Templates { get; set; }
|
||||
public ICollection<WorkOrder>? workOrders { get; set; }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
w.ServiceNameSnapshot,
|
||||
w.SiteCode,
|
||||
LocationName = w.Locations != null ? w.Locations.Name : null,
|
||||
SiteNotes = w.Locations != null ? w.Locations.Notes : null,
|
||||
WoPocName = w.PocName,
|
||||
WoPocPhone = w.PocPhone,
|
||||
WoPocNotes = w.PocNotes,
|
||||
|
|
@ -134,7 +135,7 @@ namespace SeaHaven.DataServices.Helpers
|
|||
var pocPhone = primaryFrozenPoc?.Phone
|
||||
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
|
||||
var pocNotes = frozenPoc?.Notes
|
||||
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
|
||||
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes, w.SiteNotes);
|
||||
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
|
||||
? w.DispatchTechPhone
|
||||
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(n => n.IsDeleted != true)
|
||||
.Select(n => new Locations
|
||||
{
|
||||
Id = n.Id,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SeaHaven.DataServices.Helpers;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
||||
namespace SeaHaven.DataServices.Implementation
|
||||
|
|
@ -15,25 +17,25 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<Locations?> GetByIdAsync(int id)
|
||||
{
|
||||
return await _context.Locations.FindAsync(id);
|
||||
return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<Locations?> GetByIdWithDetailsAsync(int id)
|
||||
{
|
||||
return await _context.Locations
|
||||
.FirstOrDefaultAsync(l => l.Id == id);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Locations>> GetAllAsync()
|
||||
{
|
||||
return await _context.Locations.ToListAsync();
|
||||
return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
|
||||
{
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.AccountId == accountId)
|
||||
.Where(l => l.AccountId == accountId && l.IsDeleted != true)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var row = await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.Id == locationId)
|
||||
.Where(l => l.Id == locationId && l.IsDeleted != true)
|
||||
.Select(l => new { l.AccountId })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
int pageSize,
|
||||
string? search = null)
|
||||
{
|
||||
var query = _context.Locations.AsQueryable();
|
||||
var query = _context.Locations.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -80,7 +82,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
var query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.AccountId != null);
|
||||
.Where(l => l.AccountId != null && l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -123,12 +125,12 @@ namespace SeaHaven.DataServices.Implementation
|
|||
|
||||
public async Task<bool> ExistsAsync(int id)
|
||||
{
|
||||
return await _context.Locations.AnyAsync(l => l.Id == id);
|
||||
return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync()
|
||||
{
|
||||
return await _context.Locations.CountAsync();
|
||||
return await _context.Locations.CountAsync(l => l.IsDeleted != true);
|
||||
}
|
||||
|
||||
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||
|
|
@ -142,7 +144,8 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
IQueryable<Locations> query = _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account);
|
||||
.Include(l => l.Account)
|
||||
.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
@ -200,15 +203,16 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Include(l => l.Account)
|
||||
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Locations
|
||||
.Include(l => l.Contacts)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
|
||||
|
|
@ -241,31 +245,67 @@ namespace SeaHaven.DataServices.Implementation
|
|||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||
public async Task<bool> SiteCodeExistsAsync(
|
||||
string siteCode,
|
||||
int? accountId,
|
||||
int? excludeLocationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _context.Locations
|
||||
.Include(l => l.Contacts)
|
||||
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||
if (entity == null)
|
||||
return false;
|
||||
var normalized = siteCode.Trim().ToUpperInvariant();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var contact in entity.Contacts ?? Enumerable.Empty<Contacts>())
|
||||
{
|
||||
if (contact.IsDeleted != true)
|
||||
{
|
||||
contact.IsDeleted = true;
|
||||
contact.DeletionTime = now;
|
||||
}
|
||||
|
||||
contact.LocationId = null;
|
||||
}
|
||||
|
||||
_context.Locations.Remove(entity);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
return await _context.Locations
|
||||
.AsNoTracking()
|
||||
.Where(l => l.IsDeleted != true
|
||||
&& l.AccountId == accountId
|
||||
&& l.Name != null
|
||||
&& l.Name.Trim().ToUpper() == normalized)
|
||||
.Where(l => excludeLocationId == null || l.Id != excludeLocationId)
|
||||
.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
|
||||
int locationId,
|
||||
string? siteCode,
|
||||
int? siteAccountId,
|
||||
int? callerAccountId,
|
||||
int maxIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var code = string.IsNullOrWhiteSpace(siteCode) ? null : siteCode.Trim().ToUpper();
|
||||
|
||||
var query = _context.workOrders
|
||||
.AsNoTracking()
|
||||
.Where(w => w.IsDeleted != true
|
||||
&& (w.LocationId == locationId
|
||||
|| (code != null
|
||||
&& w.LocationId == null
|
||||
&& w.SiteCode != null
|
||||
&& w.SiteCode.Trim().ToUpper() == code
|
||||
&& (w.AccountId == null || w.AccountId == siteAccountId))));
|
||||
|
||||
if (callerAccountId is int accountId)
|
||||
query = query.Where(w => w.AccountId == accountId);
|
||||
|
||||
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
|
||||
|
||||
var count = await query.CountAsync(cancellationToken);
|
||||
if (count == 0)
|
||||
return (0, Array.Empty<int>());
|
||||
|
||||
var ids = await query
|
||||
.OrderBy(w => w.Id)
|
||||
.Select(w => w.Id)
|
||||
.Take(maxIds)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (count, ids);
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
|
||||
.GetValues<LifecycleStatus>()
|
||||
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
|
||||
.ToList();
|
||||
|
||||
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
|
|
@ -273,7 +313,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
{
|
||||
search ??= "";
|
||||
|
||||
var query = _context.Locations.AsQueryable();
|
||||
var query = _context.Locations.Where(l => l.IsDeleted != true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
|
||||
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
|
||||
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
|
||||
=> _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken);
|
||||
|
||||
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
|
||||
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))
|
||||
|
|
|
|||
|
|
@ -52,6 +52,31 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
|
||||
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
||||
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// True when a live (not deleted) site of the same account already uses
|
||||
/// <paramref name="siteCode"/>, compared trimmed and case-insensitively.
|
||||
/// A null account matches only other sites without an account.
|
||||
/// </summary>
|
||||
Task<bool> SiteCodeExistsAsync(
|
||||
string siteCode,
|
||||
int? accountId,
|
||||
int? excludeLocationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Open (not Completed or Canceled, legacy status aware) and not deleted work
|
||||
/// orders that reference the site by id, or by site code when they carry no
|
||||
/// location id and no other account. <paramref name="callerAccountId"/>
|
||||
/// narrows to one account. Returns the full count and at most
|
||||
/// <paramref name="maxIds"/> ids, ascending.
|
||||
/// </summary>
|
||||
Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
|
||||
int locationId,
|
||||
string? siteCode,
|
||||
int? siteAccountId,
|
||||
int? callerAccountId,
|
||||
int maxIds,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal file
174
SeaHaven.Services.Tests/TeamEffectivePermissionsTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using SeaHaven.DataServices.Dto;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using System.Security.Claims;
|
||||
using Xunit;
|
||||
|
||||
namespace SeaHaven.Services.Tests;
|
||||
|
||||
public sealed class TeamEffectivePermissionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Scheduler_Receives_Role_Defaults()
|
||||
{
|
||||
var data = new FakeUsers().Add("u1", "Scheduler");
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
|
||||
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value!.Permissions.Should().Contain(new[]
|
||||
{
|
||||
TeamPermissionKeys.CreateCompletionDocTemplates,
|
||||
TeamPermissionKeys.EditCompletionDocTemplates
|
||||
});
|
||||
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.DeleteCompletionDocTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Override_Adds_A_Key_Outside_The_Role_Defaults()
|
||||
{
|
||||
var data = new FakeUsers().Add(
|
||||
"u1", "Dispatcher", (TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow));
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
|
||||
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_Override_Removes_A_Role_Default()
|
||||
{
|
||||
var data = new FakeUsers().Add(
|
||||
"u1", "Scheduler", (TeamPermissionKeys.EditCompletionDocTemplates, UserPermissionState.Deny));
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
|
||||
result.Value.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_Holds_Every_Key_Even_With_A_Revoke_Override()
|
||||
{
|
||||
var data = new FakeUsers().Add(
|
||||
"u1", "Admin", (TeamPermissionKeys.DeleteCompletionDocTemplates, UserPermissionState.Deny));
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().Equal(TeamPermissionKeys.All);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Keys_Match_What_The_Write_Path_Enforces()
|
||||
{
|
||||
var data = new FakeUsers().Add(
|
||||
"u1", "Dispatcher",
|
||||
(TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow),
|
||||
(TeamPermissionKeys.CreateSites, UserPermissionState.Deny));
|
||||
var policy = new TeamPermissionPolicy();
|
||||
var user = await data.GetUserAsync("u1", CancellationToken.None);
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
|
||||
|
||||
result.Value!.Permissions.Should().Equal(
|
||||
TeamPermissionKeys.All.Where(key => policy.IsAllowed(user!.RoleName, key, user.Overrides)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_Only_The_Caller_Identified_By_The_Token()
|
||||
{
|
||||
var data = new FakeUsers()
|
||||
.Add("u1", "Dispatcher")
|
||||
.Add("u2", "Dispatcher", (TeamPermissionKeys.DeleteWorkOrders, UserPermissionState.Allow));
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), cancellation.Token);
|
||||
|
||||
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.DeleteWorkOrders);
|
||||
data.RequestedUserIds.Should().Equal("u1");
|
||||
data.LastToken.Should().Be(cancellation.Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthenticated_Caller_Is_Rejected_Before_Data_Access()
|
||||
{
|
||||
var data = new FakeUsers().Add("u1", "Admin");
|
||||
var anonymous = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }));
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(anonymous, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
|
||||
data.RequestedUserIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_Without_A_User_Id_Is_Rejected_Before_Data_Access()
|
||||
{
|
||||
var data = new FakeUsers().Add("u1", "Admin");
|
||||
var noId = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(noId, CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
|
||||
data.RequestedUserIds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Token_For_A_Removed_User_Is_Rejected()
|
||||
{
|
||||
var data = new FakeUsers();
|
||||
|
||||
var result = await Service(data).GetEffectivePermissionsAsync(Caller("gone"), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
|
||||
result.Value.Should().BeNull();
|
||||
}
|
||||
|
||||
private static TeamPermissionService Service(FakeUsers data) =>
|
||||
new(data, new TeamPermissionPolicy());
|
||||
|
||||
private static ClaimsPrincipal Caller(string userId) =>
|
||||
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"));
|
||||
|
||||
private sealed class FakeUsers : ITeamPermissionOverrideDataService
|
||||
{
|
||||
private readonly Dictionary<string, TeamPermissionUserData> _users = new();
|
||||
|
||||
public List<string> RequestedUserIds { get; } = new();
|
||||
public CancellationToken LastToken { get; private set; }
|
||||
|
||||
public FakeUsers Add(string userId, string role, params (string Key, UserPermissionState State)[] overrides)
|
||||
{
|
||||
_users[userId] = new TeamPermissionUserData
|
||||
{
|
||||
UserId = userId,
|
||||
RoleName = role,
|
||||
Overrides = overrides.ToDictionary(o => o.Key, o => o.State, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestedUserIds.Add(userId);
|
||||
LastToken = cancellationToken;
|
||||
return Task.FromResult(_users.GetValueOrDefault(userId));
|
||||
}
|
||||
|
||||
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException("Reading permissions must not write.");
|
||||
|
||||
public Task SetOverridesAsync(string userId, IReadOnlyDictionary<string, UserPermissionState> overrides, CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException("Reading permissions must not write.");
|
||||
|
||||
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException("Reading permissions must not write.");
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? CreatedDate { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? ContactEmail { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
|
||||
public List<SiteContactRequestDTO>? Contacts { get; set; }
|
||||
|
|
@ -88,6 +90,9 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? Status { get; set; }
|
||||
public int? AccountId { get; set; }
|
||||
|
||||
/// <summary>Null keeps the stored notes.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
|
||||
public List<SiteContactRequestDTO>? Contacts { get; set; }
|
||||
}
|
||||
|
|
@ -99,4 +104,18 @@ namespace SeaHaven.Services.DTOs
|
|||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Site contacts and notes edited from the work-order Site dialog.</summary>
|
||||
public class SiteContactInfoRequestDTO
|
||||
{
|
||||
public List<SiteContactRequestDTO>? Contacts { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Open (not Completed or Canceled) work orders that reference a site.</summary>
|
||||
public class SiteOpenWorkOrdersDTO
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public IReadOnlyList<int> WorkOrderIds { get; set; } = Array.Empty<int>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ public sealed class TeamPermissionValueDTO
|
|||
public bool IsGranted { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EffectivePermissionsDTO
|
||||
{
|
||||
public required IReadOnlyList<string> Permissions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SetTeamPermissionOverrideDTO
|
||||
{
|
||||
public UserPermissionState State { get; init; }
|
||||
|
|
@ -24,6 +29,7 @@ public sealed class SetTeamPermissionOverrideDTO
|
|||
public enum TeamPermissionResultStatus
|
||||
{
|
||||
Success,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
InvalidPermissionKey
|
||||
|
|
|
|||
24
SeaHaven.Services/Exceptions/SiteExceptions.cs
Normal file
24
SeaHaven.Services/Exceptions/SiteExceptions.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
namespace SeaHaven.Services.Exceptions;
|
||||
|
||||
/// <summary>Another live site of the same client already uses the requested site code.</summary>
|
||||
public sealed class SiteCodeConflictException : Exception
|
||||
{
|
||||
public const string ErrorCode = "DuplicateSiteCode";
|
||||
public const string PublicMessage = "This site code already exists.";
|
||||
|
||||
public SiteCodeConflictException()
|
||||
: base(PublicMessage)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The caller lacks the permission required for a site mutation.</summary>
|
||||
public sealed class SiteForbiddenException : Exception
|
||||
{
|
||||
public const string DeleteDeniedMessage = "You are not allowed to delete sites.";
|
||||
|
||||
public SiteForbiddenException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ namespace SeaHaven.Services.Helpers
|
|||
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
|
||||
WorkOrderPocSiteContact.DisplayName(sitePrimary));
|
||||
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
|
||||
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
|
||||
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes, location?.Notes);
|
||||
|
||||
var contacts = new List<WorkOrderFrozenPocContact>();
|
||||
AddContact(contacts, pocName, pocPhone);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ using Data.SeaHavenIndustries;
|
|||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Constants;
|
||||
using SeaHaven.Services.DTOs;
|
||||
using SeaHaven.Services.Exceptions;
|
||||
using SeaHaven.Services.Helpers;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using SeaHaven.Services.Validation;
|
||||
|
|
@ -20,17 +22,26 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IAccountDataService _accountDataService;
|
||||
private readonly ICreateLocationValidation _createValidator;
|
||||
private readonly IUpdateLocationValidation _updateValidator;
|
||||
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
|
||||
private readonly ITeamPermissionPolicy _permissionPolicy;
|
||||
|
||||
/// <summary>Upper bound on ids returned for the open work orders link.</summary>
|
||||
public const int MaxOpenWorkOrderIds = 200;
|
||||
|
||||
public LocationService(
|
||||
ILocationDataService locationDataService,
|
||||
IAccountDataService accountDataService,
|
||||
ICreateLocationValidation createValidator,
|
||||
IUpdateLocationValidation updateValidator)
|
||||
IUpdateLocationValidation updateValidator,
|
||||
ITeamPermissionOverrideDataService permissionOverrideData,
|
||||
ITeamPermissionPolicy permissionPolicy)
|
||||
{
|
||||
_locationDataService = locationDataService;
|
||||
_accountDataService = accountDataService;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
_permissionOverrideData = permissionOverrideData;
|
||||
_permissionPolicy = permissionPolicy;
|
||||
}
|
||||
|
||||
// Query operations
|
||||
|
|
@ -262,10 +273,22 @@ namespace SeaHaven.Services.Implementation
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken);
|
||||
ThrowOnMissingSiteFields(request);
|
||||
|
||||
var siteCode = request.Name?.Trim();
|
||||
if (string.IsNullOrEmpty(siteCode))
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure(nameof(LocationCreateRequestDTO.Name), "Site Code is required.")
|
||||
});
|
||||
}
|
||||
|
||||
await EnsureSiteCodeAvailableAsync(siteCode, request.AccountId, excludeLocationId: null, cancellationToken);
|
||||
|
||||
var location = new Locations
|
||||
{
|
||||
Name = request.Name,
|
||||
Name = siteCode,
|
||||
Title = request.Title,
|
||||
Address1 = request.Address,
|
||||
City = request.City,
|
||||
|
|
@ -274,13 +297,11 @@ namespace SeaHaven.Services.Implementation
|
|||
PhoneNumber = request.Phone,
|
||||
Email = request.ContactEmail,
|
||||
Status = request.Status,
|
||||
AccountId = request.AccountId
|
||||
AccountId = request.AccountId,
|
||||
Notes = NormalizeNotes(request.Notes)
|
||||
};
|
||||
|
||||
if (request.Contacts is List<SiteContactRequestDTO> contacts)
|
||||
{
|
||||
ApplySiteContactsForCreate(location, contacts, GetActorId(user));
|
||||
}
|
||||
ApplySiteContactsForCreate(location, request.Contacts!, GetActorId(user));
|
||||
|
||||
await _locationDataService.AddAsync(location, cancellationToken);
|
||||
}
|
||||
|
|
@ -297,7 +318,6 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
EnsureLocationInCallerScope(user, location.AccountId);
|
||||
|
||||
location.Name = request.Name;
|
||||
location.Title = request.Title;
|
||||
location.Address1 = request.Address;
|
||||
location.City = request.City;
|
||||
|
|
@ -305,14 +325,19 @@ namespace SeaHaven.Services.Implementation
|
|||
location.Zip = request.ZipCode;
|
||||
location.PhoneNumber = request.Phone;
|
||||
location.Email = request.ContactEmail;
|
||||
location.Status = request.Status;
|
||||
location.Status = request.Status ?? location.Status;
|
||||
if (request.Notes != null)
|
||||
location.Notes = NormalizeNotes(request.Notes);
|
||||
|
||||
var previousAccountId = location.AccountId;
|
||||
if (request.AccountId is int accountId)
|
||||
{
|
||||
await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken);
|
||||
location.AccountId = accountId;
|
||||
}
|
||||
|
||||
await ApplySiteCodeForUpdateAsync(location, request.Name, previousAccountId, cancellationToken);
|
||||
|
||||
// SH-138: null contacts keeps legacy behavior and must not mutate contact rows.
|
||||
if (request.Contacts is List<SiteContactRequestDTO> contacts)
|
||||
{
|
||||
|
|
@ -322,11 +347,147 @@ namespace SeaHaven.Services.Implementation
|
|||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken)
|
||||
public async Task UpdateSiteContactInfoAsync(
|
||||
int id,
|
||||
SiteContactInfoRequestDTO request,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _locationDataService.DeleteByIdAsync(id, cancellationToken);
|
||||
if (request.Contacts is not List<SiteContactRequestDTO> contacts)
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure(nameof(SiteContactInfoRequestDTO.Contacts), "At least one contact is required.")
|
||||
});
|
||||
}
|
||||
|
||||
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
|
||||
if (location == null)
|
||||
throw new KeyNotFoundException($"Location with ID {id} not found");
|
||||
|
||||
EnsureLocationInCallerScope(user, location.AccountId);
|
||||
|
||||
ApplySiteContactsForUpdate(location, contacts, GetActorId(user));
|
||||
location.Notes = NormalizeNotes(request.Notes);
|
||||
|
||||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a site for good from the caller's point of view. The row is kept as
|
||||
/// a tombstone because work orders, assets and history reference it through
|
||||
/// restrict foreign keys; those references are left exactly as they were.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteLocationByIdAsync(
|
||||
int id,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureCanDeleteSitesAsync(user, cancellationToken);
|
||||
|
||||
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
|
||||
if (location == null)
|
||||
return false;
|
||||
|
||||
EnsureLocationInCallerScope(user, location.AccountId);
|
||||
|
||||
location.IsDeleted = true;
|
||||
location.DeletionTime = DateTime.UtcNow;
|
||||
location.DeleterUserId = GetActorId(user);
|
||||
|
||||
await _locationDataService.UpdateAsync(location, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<SiteOpenWorkOrdersDTO?> GetOpenWorkOrdersAsync(
|
||||
int id,
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);
|
||||
if (location == null)
|
||||
return null;
|
||||
|
||||
EnsureLocationInCallerScope(user, location.AccountId);
|
||||
|
||||
int? callerAccountId = WorkOrderMediaAuthorization.ResolveMediaScope(user) is MediaAccountScope.Account caller
|
||||
? caller.AccountId
|
||||
: null;
|
||||
|
||||
var (count, ids) = await _locationDataService.GetOpenWorkOrderIdsAsync(
|
||||
location.Id,
|
||||
location.Name,
|
||||
location.AccountId,
|
||||
callerAccountId,
|
||||
MaxOpenWorkOrderIds,
|
||||
cancellationToken);
|
||||
|
||||
return new SiteOpenWorkOrdersDTO { Count = count, WorkOrderIds = ids };
|
||||
}
|
||||
|
||||
private async Task EnsureCanDeleteSitesAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = GetActorId(user);
|
||||
var permissionUser = string.IsNullOrWhiteSpace(userId)
|
||||
? null
|
||||
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
|
||||
|
||||
if (permissionUser is null
|
||||
|| !_permissionPolicy.IsAllowed(
|
||||
permissionUser.RoleName,
|
||||
TeamPermissionKeys.DeleteSites,
|
||||
permissionUser.Overrides))
|
||||
{
|
||||
throw new SiteForbiddenException(SiteForbiddenException.DeleteDeniedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureSiteCodeAvailableAsync(
|
||||
string siteCode,
|
||||
int? accountId,
|
||||
int? excludeLocationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _locationDataService.SiteCodeExistsAsync(siteCode, accountId, excludeLocationId, cancellationToken))
|
||||
throw new SiteCodeConflictException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The site code is immutable once set. A blank legacy code may be filled in
|
||||
/// once; the code must stay unique within the site's account, including when
|
||||
/// the site moves to another account.
|
||||
/// </summary>
|
||||
private async Task ApplySiteCodeForUpdateAsync(
|
||||
Locations location,
|
||||
string? requestedCode,
|
||||
int? previousAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stored = location.Name?.Trim();
|
||||
var requested = requestedCode?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(stored)
|
||||
&& !string.IsNullOrEmpty(requested)
|
||||
&& !string.Equals(stored, requested, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ValidationException(new[]
|
||||
{
|
||||
new ValidationFailure(nameof(LocationUpdateRequestDTO.Name), "Site Code cannot be changed.")
|
||||
});
|
||||
}
|
||||
|
||||
var fillsBlankCode = string.IsNullOrEmpty(stored) && !string.IsNullOrEmpty(requested);
|
||||
var code = fillsBlankCode ? requested : stored;
|
||||
if (!string.IsNullOrEmpty(code) && (fillsBlankCode || previousAccountId != location.AccountId))
|
||||
await EnsureSiteCodeAvailableAsync(code, location.AccountId, location.Id, cancellationToken);
|
||||
|
||||
if (fillsBlankCode)
|
||||
location.Name = requested;
|
||||
}
|
||||
|
||||
private static string? NormalizeNotes(string? notes) =>
|
||||
string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
|
||||
|
||||
private static void EnsureLocationInCallerScope(ClaimsPrincipal user, int? locationAccountId)
|
||||
{
|
||||
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
|
||||
|
|
@ -385,6 +546,25 @@ namespace SeaHaven.Services.Implementation
|
|||
private static string? GetActorId(ClaimsPrincipal user) =>
|
||||
user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
/// <summary>A new site needs a client, a full address and at least one point of contact.</summary>
|
||||
private static void ThrowOnMissingSiteFields(LocationCreateRequestDTO request)
|
||||
{
|
||||
var failures = new List<ValidationFailure>();
|
||||
if (request.AccountId == null)
|
||||
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Client is required."));
|
||||
if (string.IsNullOrWhiteSpace(request.Address))
|
||||
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Address), "Street Address is required."));
|
||||
if (string.IsNullOrWhiteSpace(request.City))
|
||||
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.City), "City is required."));
|
||||
if (string.IsNullOrWhiteSpace(request.State))
|
||||
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.State), "State is required."));
|
||||
if (request.Contacts == null || request.Contacts.Count == 0)
|
||||
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Contacts), "At least one contact is required."));
|
||||
|
||||
if (failures.Count > 0)
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
private static void ThrowOnInvalidContacts(List<SiteContactRequestDTO> contacts)
|
||||
{
|
||||
var failures = SiteContactsValidation.Validate(contacts);
|
||||
|
|
@ -414,9 +594,6 @@ namespace SeaHaven.Services.Implementation
|
|||
})
|
||||
.ToList();
|
||||
location.Contacts = siteContacts;
|
||||
|
||||
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
|
||||
location.PhoneNumber = siteContacts[0].PhoneNumber;
|
||||
}
|
||||
|
||||
private static void ApplySiteContactsForUpdate(
|
||||
|
|
@ -489,9 +666,6 @@ namespace SeaHaven.Services.Implementation
|
|||
existing.DeletionTime = now;
|
||||
existing.LastModificationTime = now;
|
||||
}
|
||||
|
||||
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
|
||||
location.PhoneNumber = contacts[0].Phone!.Trim();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<int, IReadOnlyList<Contacts>>> GetSiteContactsByLocationIdsAsync(
|
||||
|
|
@ -544,6 +718,7 @@ namespace SeaHaven.Services.Implementation
|
|||
Status = location.Status,
|
||||
AccountId = location.AccountId,
|
||||
AccountName = location.Account?.Name,
|
||||
Notes = location.Notes,
|
||||
CreatedDate = location.CreatedDate,
|
||||
CreatedBy = location.createdby,
|
||||
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,28 @@ public sealed class TeamPermissionService : ITeamPermissionService
|
|||
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = caller?.Identity?.IsAuthenticated == true
|
||||
? caller.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
: null;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
|
||||
|
||||
var user = await _dataService.GetUserAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
|
||||
|
||||
return TeamPermissionResult<EffectivePermissionsDTO>.Success(new EffectivePermissionsDTO
|
||||
{
|
||||
Permissions = TeamPermissionKeys.All
|
||||
.Where(key => _policy.IsAllowed(user.RoleName, key, user.Overrides))
|
||||
.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ namespace SeaHaven.Services.Interfaces
|
|||
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
|
||||
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken);
|
||||
Task UpdateSiteContactInfoAsync(int id, SiteContactInfoRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
Task<bool> DeleteLocationByIdAsync(int id, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
Task<SiteOpenWorkOrdersDTO?> GetOpenWorkOrdersAsync(int id, ClaimsPrincipal user, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ public interface ITeamPermissionService
|
|||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Keys the caller holds after role defaults and their own overrides. The
|
||||
/// user is read from the caller's identity, never from request input.
|
||||
/// </summary>
|
||||
Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
|
||||
ClaimsPrincipal caller,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
|
||||
string userId,
|
||||
string permissionKey,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using Microsoft.Data.SqlClient;
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
|
|
@ -84,150 +83,6 @@ public class SH138SiteContactsSqlServerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SH138_LocationDelete_DetachesSoftDeletedContacts_WithRestrictFk_WhenLocalDbAvailable()
|
||||
{
|
||||
var masterConnectionString = await ResolveMasterConnectionStringAsync();
|
||||
if (masterConnectionString == null)
|
||||
return;
|
||||
|
||||
var dbName = $"SH138LocationDelete_{Guid.NewGuid():N}";
|
||||
var connectionString = WithDatabase(masterConnectionString, dbName);
|
||||
|
||||
try
|
||||
{
|
||||
await CreateDatabaseAsync(masterConnectionString, dbName);
|
||||
|
||||
await using var connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using (var createTables = connection.CreateCommand())
|
||||
{
|
||||
createTables.CommandText =
|
||||
"""
|
||||
CREATE TABLE Locations (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
AccountId int NULL,
|
||||
Title nvarchar(max) NULL,
|
||||
Name nvarchar(max) NULL,
|
||||
Latitude nvarchar(max) NULL,
|
||||
Longitude nvarchar(max) NULL,
|
||||
Address1 nvarchar(max) NULL,
|
||||
Address2 nvarchar(max) NULL,
|
||||
City nvarchar(max) NULL,
|
||||
State nvarchar(max) NULL,
|
||||
Zip nvarchar(max) NULL,
|
||||
PhoneNumber nvarchar(max) NULL,
|
||||
Email nvarchar(max) NULL,
|
||||
Status nvarchar(max) NULL,
|
||||
ExternalSource nvarchar(max) NULL,
|
||||
ExternalLocationId nvarchar(max) NULL,
|
||||
IsDeleted bit NULL,
|
||||
createdby nvarchar(max) NULL,
|
||||
DeleterUserId nvarchar(max) NULL,
|
||||
DeletionTime datetime2 NULL,
|
||||
CreatedDate datetime2 NULL,
|
||||
LastModificationTime datetime2 NULL,
|
||||
LastModifierUserId int NULL
|
||||
);
|
||||
CREATE TABLE Contacts (
|
||||
Id int NOT NULL IDENTITY PRIMARY KEY,
|
||||
AccountId int NULL,
|
||||
LocationId int NULL,
|
||||
Title nvarchar(max) NULL,
|
||||
Owner nvarchar(max) NULL,
|
||||
FirstName nvarchar(max) NULL,
|
||||
MiddleName nvarchar(max) NULL,
|
||||
LastName nvarchar(max) NULL,
|
||||
ContactType nvarchar(max) NULL,
|
||||
PhoneNumber nvarchar(max) NULL,
|
||||
Email nvarchar(max) NULL,
|
||||
Address1 nvarchar(max) NULL,
|
||||
Address2 nvarchar(max) NULL,
|
||||
City nvarchar(max) NULL,
|
||||
State nvarchar(max) NULL,
|
||||
Zip nvarchar(max) NULL,
|
||||
FacebookUrl nvarchar(max) NULL,
|
||||
LinkedInUrl nvarchar(max) NULL,
|
||||
TwitterUrl nvarchar(max) NULL,
|
||||
IsDeleted bit NULL,
|
||||
createdby nvarchar(max) NULL,
|
||||
DeleterUserId nvarchar(max) NULL,
|
||||
DeletionTime datetime2 NULL,
|
||||
CreatedDate datetime2 NULL,
|
||||
LastModificationTime datetime2 NULL,
|
||||
LastModifierUserId int NULL,
|
||||
SiteContactOrder int NULL,
|
||||
CONSTRAINT FK_Contacts_Locations_LocationId FOREIGN KEY (LocationId) REFERENCES Locations (Id) ON DELETE NO ACTION
|
||||
);
|
||||
SET IDENTITY_INSERT Locations ON;
|
||||
INSERT INTO Locations (Id, Name) VALUES (11, 'Depot');
|
||||
SET IDENTITY_INSERT Locations OFF;
|
||||
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, SiteContactOrder) VALUES (11, 'Alice Cooper', '555-0100', 0);
|
||||
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, IsDeleted, DeleterUserId, DeletionTime) VALUES (11, 'Bob Dillon', '555-0200', 1, 'actor-1', '2026-01-01T00:00:00');
|
||||
""";
|
||||
await createTables.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlServer(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
var service = new LocationDataService(context);
|
||||
var deleted = await service.DeleteByIdAsync(11, CancellationToken.None);
|
||||
|
||||
Assert.True(deleted);
|
||||
|
||||
await using (var locationCmd = connection.CreateCommand())
|
||||
{
|
||||
locationCmd.CommandText = "SELECT COUNT(*) FROM Locations;";
|
||||
var locationCount = await locationCmd.ExecuteScalarAsync();
|
||||
Assert.Equal(0, Convert.ToInt32(locationCount));
|
||||
}
|
||||
|
||||
await using (var activeCmd = connection.CreateCommand())
|
||||
{
|
||||
activeCmd.CommandText =
|
||||
"""
|
||||
SELECT FirstName, PhoneNumber, LocationId, IsDeleted, DeleterUserId, DeletionTime
|
||||
FROM Contacts
|
||||
WHERE FirstName = N'Alice Cooper';
|
||||
""";
|
||||
await using var reader = await activeCmd.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal("Alice Cooper", reader.GetString(0));
|
||||
Assert.Equal("555-0100", reader.GetString(1));
|
||||
Assert.True(reader.IsDBNull(2));
|
||||
Assert.True(reader.GetBoolean(3));
|
||||
Assert.True(reader.IsDBNull(4), "delete carries no audit actor");
|
||||
Assert.False(reader.IsDBNull(5));
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
await using (var previouslyDeletedCmd = connection.CreateCommand())
|
||||
{
|
||||
previouslyDeletedCmd.CommandText =
|
||||
"""
|
||||
SELECT LocationId, IsDeleted, DeleterUserId, DeletionTime
|
||||
FROM Contacts
|
||||
WHERE FirstName = N'Bob Dillon';
|
||||
""";
|
||||
await using var reader = await previouslyDeletedCmd.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.True(reader.IsDBNull(0), "already soft-deleted rows detach so the restrict FK cannot block the delete");
|
||||
Assert.True(reader.GetBoolean(1));
|
||||
Assert.Equal("actor-1", reader.GetString(2));
|
||||
Assert.Equal(new DateTime(2026, 1, 1), reader.GetDateTime(3));
|
||||
Assert.False(await reader.ReadAsync());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DropDatabaseAsync(masterConnectionString, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
|
|
|
|||
|
|
@ -849,7 +849,19 @@ public class WorkOrderAccountScopeTests
|
|||
=> _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);
|
||||
public Task<bool> SiteCodeExistsAsync(
|
||||
string siteCode,
|
||||
int? accountId,
|
||||
int? excludeLocationId,
|
||||
CancellationToken cancellationToken)
|
||||
=> _inner.SiteCodeExistsAsync(siteCode, accountId, excludeLocationId, cancellationToken);
|
||||
public Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
|
||||
int locationId,
|
||||
string? siteCode,
|
||||
int? siteAccountId,
|
||||
int? callerAccountId,
|
||||
int maxIds,
|
||||
CancellationToken cancellationToken)
|
||||
=> _inner.GetOpenWorkOrderIdsAsync(locationId, siteCode, siteAccountId, callerAccountId, maxIds, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue