mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 17:43:12 +00:00
* 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
162 lines
5.8 KiB
C#
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();
|
|
}
|
|
}
|