mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03: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
317 lines
11 KiB
C#
317 lines
11 KiB
C#
using Api.SeaHavenIndustries.Controllers;
|
|
using Api.SeaHavenIndustries.DTOs;
|
|
using Data.SeaHavenIndustries;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Interfaces;
|
|
using System.Security.Claims;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class VendorControllerTests
|
|
{
|
|
private static VendorController NewController(Mock<IVendorService> service)
|
|
{
|
|
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
|
|
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, "42")
|
|
}, "Test"))
|
|
}
|
|
};
|
|
|
|
return controller;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
|
.ReturnsAsync(new VendorDTO());
|
|
|
|
var controller = NewController(service);
|
|
|
|
var model = new EditVendor_DTO
|
|
{
|
|
Id = 8,
|
|
CompanyName = "Updated Name"
|
|
};
|
|
|
|
var result = await controller.Update(8, model);
|
|
|
|
result.Should().BeOfType<OkObjectResult>();
|
|
service.Verify(x => x.UpdateVendorAsync(
|
|
8,
|
|
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
|
|
"42"), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
|
|
.ReturnsAsync(new VendorDTO());
|
|
|
|
var controller = NewController(service);
|
|
|
|
var model = new EditVendor_DTO
|
|
{
|
|
Id = 8,
|
|
CompanyName = "Updated Name",
|
|
IsActive = false
|
|
};
|
|
|
|
await controller.Update(8, model);
|
|
|
|
service.Verify(x => x.UpdateVendorAsync(
|
|
8,
|
|
It.Is<UpdateVendorDTO>(dto => dto.IsActive == false),
|
|
"42"), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Create_WithoutIsActive_DefaultsToActive()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
|
|
.ReturnsAsync(new VendorDTO());
|
|
|
|
var controller = NewController(service);
|
|
|
|
var model = new Vendor_DTO
|
|
{
|
|
CompanyName = "New Co"
|
|
};
|
|
|
|
await controller.Create(model);
|
|
|
|
service.Verify(x => x.CreateVendorAsync(
|
|
It.Is<CreateVendorDTO>(dto => dto.IsActive == true),
|
|
"42"), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetFacets_DelegatesToServiceAndReturnsOk()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
var facets = new VendorFacetsDTO
|
|
{
|
|
Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } },
|
|
Trades = new[] { "Plumbing" },
|
|
Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } },
|
|
JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } }
|
|
};
|
|
service.Setup(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(facets);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetFacets(true);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
ok.Value.Should().Be(facets);
|
|
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
var items = new[]
|
|
{
|
|
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
|
|
};
|
|
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(items);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetDropdown("Plumbing", "60601");
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
ok.Value.Should().Be(items);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPortalToken_ReturnsTokenDtoFromService()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" };
|
|
service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(token);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetPortalToken(5);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
ok.Value.Should().Be(token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((VendorPortalTokenDTO?)null);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetPortalToken(99);
|
|
|
|
result.Should().BeOfType<NotFoundResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RotatePortalToken_ReturnsRotatedToken()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
var token = new VendorPortalTokenDTO { Token = "new-token" };
|
|
service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(token);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RotatePortalToken(5);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
ok.Value.Should().Be(token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(true);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RevokePortalToken(5);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
ok.Value.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.RevokePortalToken(99);
|
|
|
|
result.Should().BeOfType<NotFoundResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_WhenDeactivationBlocked_ReturnsSanitizedConflictWithOpenWorkOrdersAndLogs()
|
|
{
|
|
var openWorkOrders = new List<LinkedWorkOrderDTO> { new() { WorkOrderId = 7 } };
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
|
|
.ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders));
|
|
|
|
var logger = new Mock<ILogger<VendorController>>();
|
|
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
|
var controller = new VendorController(service.Object, logger.Object);
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test"))
|
|
}
|
|
};
|
|
|
|
var result = await controller.Delete(5);
|
|
|
|
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
|
|
var value = conflict.Value!;
|
|
var message = (string)value.GetType().GetProperty("Message")!.GetValue(value)!;
|
|
message.Should().NotContain("SECRET");
|
|
message.Should().Contain("reference");
|
|
var returnedOpenWorkOrders = (List<LinkedWorkOrderDTO>)value.GetType().GetProperty("OpenWorkOrders")!.GetValue(value)!;
|
|
returnedOpenWorkOrders.Should().HaveCount(1);
|
|
logger.Verify(
|
|
x => x.Log(
|
|
LogLevel.Error,
|
|
It.IsAny<EventId>(),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_MapsDeactivationBlocked_ToConflict()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
|
|
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Delete(5);
|
|
|
|
result.Should().BeOfType<ConflictObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
|
|
.ThrowsAsync(new InvalidOperationException());
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Delete(5);
|
|
|
|
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
|
nf.Value.Should().BeOfType<Response>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
service.Setup(x => x.GetVendorByIdAsync(99))
|
|
.ReturnsAsync((VendorDTO?)null);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.GetVendorById(99, null);
|
|
|
|
result.Should().BeOfType<NotFoundObjectResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Update_UnauthenticatedUser_ReturnsUnauthorized()
|
|
{
|
|
var service = new Mock<IVendorService>();
|
|
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() }
|
|
};
|
|
|
|
var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" });
|
|
|
|
result.Should().BeOfType<UnauthorizedObjectResult>();
|
|
}
|
|
}
|