shoc-backend/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs
Alexandre Brandizzi 7d245eb717
refactor: enforce backend boundaries and optimize dispatch (#30)
* refactor(api): enforce service and data-service boundaries

* refactor(api): complete feature service boundaries

* refactor(identity): enforce service and data boundaries

* refactor(vendors): enforce service and data boundaries

* refactor(workorders): enforce service and data boundaries

* refactor(backend): enforce architecture and optimize dispatch

* style(backend): format changed architecture files

* fix(architecture): address backend review follow-ups

* fix(backend): sanitize exception disclosure in changed API endpoints

Replace raw exception-message disclosure (ex.Message) returned to API
callers with a stable sanitized public message plus correlated structured
internal logging, across the endpoints changed in this PR.

- Add SanitizedErrors helper: logs the original exception at Error with a
  generated correlation id and returns a stable public message referencing
  it so support can trace without exposing internals.
- Inject ILogger<T> into the 14 changed controllers and route every
  ex.Message/dbex.Message disclosure through the helper, preserving status
  codes, response shapes, and business data (e.g. OpenWorkOrders).
- Leave FluentValidation (vex.Errors) and existing fixed-message catches
  untouched; out-of-scope controllers (Account/Contact/Employee/Asset/
  PMSchedule) are unchanged.
- Add focused tests proving internal exception text is not returned and
  that Error logging carrying the original exception is invoked.

* fix(architecture): abstract job run state access

* style: format board update service

* test: use collection assertion idiom
2026-07-24 17:35:34 -03:00

133 lines
4.7 KiB
C#

using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx) =>
new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation());
private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active")
{
var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now };
ctx.Locations.Add(loc);
ctx.SaveChanges();
return loc;
}
[Fact]
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
{
using var ctx = NewContext();
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
Address = "1 Depot Rd",
City = "Austin",
State = "TX",
ZipCode = "73301",
Phone = "555-1000",
ContactEmail = "wh@example.com",
Status = "Active"
}, CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
entity.Title.Should().Be("Main WH");
entity.Address1.Should().Be("1 Depot Rd");
entity.City.Should().Be("Austin");
entity.State.Should().Be("TX");
entity.Zip.Should().Be("73301");
entity.PhoneNumber.Should().Be("555-1000");
entity.Email.Should().Be("wh@example.com");
entity.Status.Should().Be("Active");
}
[Fact]
public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape()
{
using var ctx = NewContext();
SeedLocation(ctx, "Alpha Site", "Austin");
SeedLocation(ctx, "Beta Site", "Dallas");
SeedLocation(ctx, "Gamma Yard", "Austin");
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
page.Items.Should().HaveCount(2);
page.TotalCount.Should().Be(2);
page.Items.All(l => l.City == "Austin").Should().BeTrue();
page.Items.Select(l => l.Name).Should().NotBeNull();
}
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Depot", "Houston");
var service = NewService(ctx);
var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None);
var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None);
missing.Should().BeNull();
found.Should().NotBeNull();
found!.Name.Should().Be("Depot");
found.City.Should().Be("Houston");
}
[Fact]
public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Old", "Round Rock");
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("New");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[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();
}
}