shoc-backend/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.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

162 lines
5.8 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 FollowUpServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static FollowUpService NewService(ApplicationDbContext ctx) =>
new(new FollowUpDataService(ctx), new CreateFollowUpValidation(), new UpdateFollowUpValidation());
private static FollowUps SeedFollowUp(ApplicationDbContext ctx, string reason, string status = "Open")
{
var f = new FollowUps
{
Reason = reason,
ScheduleDate = new DateTime(2026, 8, 1),
ScheduleStartTime = new TimeSpan(9, 0, 0),
Status = status,
createdby = "user-1",
CreatedDate = DateTime.Now
};
ctx.FollowUps.Add(f);
ctx.SaveChanges();
return f;
}
[Fact]
public async Task CreateOpenFollowUpAsync_ForcesOpenStatusAndPersistsFields()
{
using var ctx = NewContext();
var service = NewService(ctx);
var id = await service.CreateOpenFollowUpAsync(new FollowUpCreateRequestDTO
{
Reason = "Call back",
ScheduleDate = new DateTime(2026, 9, 1),
ScheduleStartTime = new TimeSpan(10, 30, 0),
EmployeeId = 5,
WorkOrderId = 7,
AccountId = 9,
LocationId = 11
}, "user-9", CancellationToken.None);
var entity = ctx.FollowUps.Single();
entity.Id.Should().Be(id);
entity.Status.Should().Be("Open");
entity.Reason.Should().Be("Call back");
entity.createdby.Should().Be("user-9");
entity.EmployeeId.Should().Be(5);
entity.WorkOrderId.Should().Be(7);
entity.CreatedDate.Should().NotBeNull();
}
[Fact]
public async Task GetFollowUpListAsync_DefaultsToOpenAndMapsShape()
{
using var ctx = NewContext();
SeedFollowUp(ctx, "Open one", "Open");
SeedFollowUp(ctx, "Done one", "Completed");
var page = await NewService(ctx).GetFollowUpListAsync(1, 12, "", null, CancellationToken.None);
page.Items.Should().HaveCount(1);
page.Items.Single().Reason.Should().Be("Open one");
page.Items.Single().Status.Should().Be("Open");
page.PageSize.Should().Be(12);
}
[Fact]
public async Task GetFollowUpDetailAsync_MapsRelatedNamesAndResolvesCreator()
{
using var ctx = NewContext();
var employee = new Employee { FirstName = "Sara", LastName = "Lee" };
ctx.Employees.Add(employee);
ctx.SaveChanges();
var account = new Accounts { Name = "Acme" };
ctx.Accounts.Add(account);
ctx.SaveChanges();
var user = new ApplicationUser { Id = "user-1", FirstName = "Creator", LastName = "One", UserName = "cone" };
var fu = new FollowUps
{
Reason = "Detail",
ScheduleDate = new DateTime(2026, 8, 2),
ScheduleStartTime = new TimeSpan(8, 0, 0),
Status = "Open",
EmployeeId = employee.Id,
AccountId = account.Id,
createdby = "user-1",
CreatedDate = DateTime.Now
};
ctx.Users.Add(user);
ctx.FollowUps.Add(fu);
ctx.SaveChanges();
var detail = await NewService(ctx).GetFollowUpDetailAsync(fu.Id, CancellationToken.None);
detail.Should().NotBeNull();
detail!.AssignedTo.Should().Be("Sara Lee");
detail.AccountName.Should().Be("Acme");
detail.CreatedById.Should().Be("user-1");
detail.CreatedByName.Should().Be("Creator One");
}
[Fact]
public async Task UpdateFollowUpFieldsAsync_UpdatesAndThrowsWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "Old reason");
await NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id, new FollowUpUpdateRequestDTO
{
Id = existing.Id,
Reason = "New reason",
ScheduleDate = new DateTime(2026, 10, 1),
ScheduleStartTime = new TimeSpan(11, 0, 0)
}, CancellationToken.None);
var row = ctx.FollowUps.Single();
row.Reason.Should().Be("New reason");
var act = () => NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id + 999, new FollowUpUpdateRequestDTO { Id = existing.Id + 999 }, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task SetFollowUpStatusAsync_TogglesStatusAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "To complete");
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id, "Completed", CancellationToken.None)).Should().BeTrue();
ctx.FollowUps.Single().Status.Should().Be("Completed");
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id + 999, "Completed", CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task RemoveFollowUpAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "Gone");
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeTrue();
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeFalse();
ctx.FollowUps.Should().BeEmpty();
}
}