Hide deleted sites from every location read; require the Add Site fields on create

- Legacy reads (by id, all, by client, paged, address book, exists, count) and the vendor
  preference site check now skip tombstoned sites
- Create requires a client, street address, city, state and at least one complete contact
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:53:29 -03:00
parent 60b1afd8e0
commit 92dabbfbe3
6 changed files with 149 additions and 36 deletions

View file

@ -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>()
@ -85,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",
@ -97,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");
@ -390,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);
@ -406,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);
@ -422,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);
@ -457,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);
@ -487,7 +508,7 @@ public class LocationServiceTests
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);

View file

@ -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>()
@ -53,7 +74,7 @@ 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",
@ -63,7 +84,7 @@ public class LocationSiteContactsTests
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();
}
@ -74,7 +95,7 @@ public class LocationSiteContactsTests
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,
@ -83,7 +104,7 @@ 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().BeNull("Site Phone is independent of the contacts");
@ -107,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");
@ -130,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)
{
@ -151,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"));
@ -173,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"));

View file

@ -423,13 +423,15 @@ public class SiteRegistryServiceTests
using var cts = new CancellationTokenSource();
var token = cts.Token;
var data = new Mock<ILocationDataService>();
data.Setup(d => d.SiteCodeExistsAsync("BK5", null, null, token)).ReturnsAsync(true);
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
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", null), OrgWide(), token);
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token);
await act.Should().ThrowAsync<SiteCodeConflictException>();
data.Verify(d => d.SiteCodeExistsAsync("BK5", null, null, token), Times.Once);
data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once);
}
[Fact]
@ -446,4 +448,56 @@ public class SiteRegistryServiceTests
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();
}
}

View file

@ -17,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();
}
@ -57,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))
{
@ -125,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(
@ -313,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))
{

View file

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

View file

@ -273,6 +273,7 @@ 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))
@ -300,10 +301,7 @@ namespace SeaHaven.Services.Implementation
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);
}
@ -548,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);