shoc-backend/Api.SeaHavenIndustries.Tests/ServicesRegistryServiceTests.cs
2026-09-16 22:30:32 -03:00

396 lines
17 KiB
C#

using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
// Global Services registry behavior. The registry has no tenant scope
// (no AccountId), authorization is enforced at the service boundary, and the
// only allowed supported work-order types are PM, Reactive, and Emergency.
public class ServicesRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ServicesRegistryService NewService(ApplicationDbContext ctx) =>
new(new ServicesRegistryDataService(ctx));
private static ClaimsPrincipal Principal(params string[] roles) =>
new(new ClaimsIdentity(roles.Select(r => new Claim(ClaimTypes.Role, r)), "Test"));
private static ClaimsPrincipal Admin() => Principal("Admin");
private static ClaimsPrincipal Scheduler() => Principal("Scheduler");
private static ServiceInput Input(
string name = "Leak",
string? trade = "Plumbing & Water Systems",
string? iconKey = "plumbing-water-systems",
bool requiresDoc = false,
int? templateId = null,
List<WorkOrderType>? types = null) => new()
{
Name = name,
Trade = trade,
IconKey = iconKey,
RequiresCompletionDocument = requiresDoc,
CompletionDocTemplateId = templateId,
SupportedWorkOrderTypes = types ?? new List<WorkOrderType>
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
}
};
private static CompletionDocTemplate Template(string name, bool active = true, bool deleted = false) =>
new() { Name = name, ServiceKey = "test", TemplateUrl = "https://example.com/t.pdf", IsActive = active, IsDeleted = deleted };
[Fact]
public async Task Create_PersistsActiveServiceWithNormalizedFieldsAndSupportedTypes()
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Scheduler(), Input(" Leak Detection "), CancellationToken.None);
dto.Id.Should().BeGreaterThan(0);
dto.Name.Should().Be("Leak Detection");
dto.Trade.Should().Be("Plumbing & Water Systems");
dto.IconKey.Should().Be("plumbing-water-systems");
dto.IsActive.Should().BeTrue("new services are always active");
dto.CompletionDocTemplate.Should().BeNull();
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[] { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency });
var row = ctx.Services.Include(s => s.SupportedWorkOrderTypes).Single();
row.NormalizedName.Should().Be("leak detection");
}
[Fact]
public async Task Create_WhenTypeCategoriesOmitted_DefaultsToSupportedWorkOrderTypes()
{
using var ctx = NewContext();
var input = Input(types: null);
input.SupportedWorkOrderTypes = null;
var dto = await NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
dto.SupportedWorkOrderTypes.Should().BeEquivalentTo(new[]
{
WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency
});
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenNameBlank_ThrowsNameRequired(string? name)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(name: name!), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Subject.Single();
ex.Code.Should().Be("NameRequired");
}
[Fact]
public async Task Create_WhenTradeNotCanonical_ThrowsTradeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(trade: "Roofing"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TradeInvalid");
}
[Theory]
[InlineData(" hvac ")]
[InlineData("HVAC")]
[InlineData("hvac")]
public async Task Create_AcceptsCanonicalTradeInAnyCasingOrPadding(string trade)
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(Admin(), Input(name: "Cooling", trade: trade, iconKey: "hvac"), CancellationToken.None);
dto.Trade.Should().Be("HVAC");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Create_WhenIconKeyBlank_ThrowsIconKeyInvalid(string? iconKey)
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Admin(), Input(iconKey: iconKey!), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("IconKeyInvalid");
}
[Fact]
public async Task Create_WhenNormalizedNameExists_ThrowsDuplicateName_RegardlessOfCasing()
{
using var ctx = NewContext();
var service = NewService(ctx);
await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Admin(), Input(" LEAK "), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Registry_IsGlobal_SingleScopeWithoutAccountOrTenantFilter()
{
using var ctx = NewContext();
var service = NewService(ctx);
// Distinct actors with different role sets still share one global registry.
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.CreateAsync(Scheduler(), Input("Leak"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
var all = await service.GetAllAsync(null, null, CancellationToken.None);
all.Should().ContainSingle(s => s.Id == first.Id);
typeof(Service).GetProperty("AccountId").Should().BeNull("tenant scope is global, matching DropdownOptions");
}
[Fact]
public async Task Create_WhenCallerLacksSchedulerAndAdminRole_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal("User"), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenCallerUnauthenticated_ThrowsForbidden()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(Principal(), Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid()
{
using var ctx = NewContext();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
ctx.CompletionDocTemplates.Add(Template("Inactive", active: false));
ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true));
ctx.SaveChanges();
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
(await new Func<Task<ServiceDto>>(() => NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None))
.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("TemplateInvalid");
}
[Fact]
public async Task Create_WithActiveTemplate_ProjectsLinkedTemplateSummary()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: true, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeTrue();
dto.CompletionDocTemplate.Should().NotBeNull();
dto.CompletionDocTemplate!.Id.Should().Be(template.Id);
dto.CompletionDocTemplate.Name.Should().Be("Completion Packet");
}
[Fact]
public async Task Create_WhenCompletionToggleOff_IgnoresTemplateLink()
{
using var ctx = NewContext();
var template = Template("Completion Packet");
ctx.CompletionDocTemplates.Add(template);
ctx.SaveChanges();
var dto = await NewService(ctx).CreateAsync(
Admin(), Input(requiresDoc: false, templateId: template.Id), CancellationToken.None);
dto.RequiresCompletionDocument.Should().BeFalse();
dto.CompletionDocTemplate.Should().BeNull();
ctx.Services.Single().CompletionDocTemplateId.Should().BeNull();
}
[Theory]
[InlineData(WorkOrderType.PO)]
[InlineData(WorkOrderType.Project)]
[InlineData(WorkOrderType.Inspection)]
[InlineData(WorkOrderType.AddOn)]
[InlineData(WorkOrderType.Other)]
public async Task Create_WhenUnsupportedWorkOrderType_ThrowsWorkOrderTypeInvalid(WorkOrderType unsupported)
{
using var ctx = NewContext();
var input = Input(types: new List<WorkOrderType> { WorkOrderType.PM, unsupported });
var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task Update_RewritesFieldsButPreservesActiveState()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var updated = await service.UpdateAsync(
Admin(), created.Id, Input("Leak Repair", trade: "HVAC", iconKey: "hvac"), CancellationToken.None);
updated.Name.Should().Be("Leak Repair");
updated.Trade.Should().Be("HVAC");
updated.IconKey.Should().Be("hvac");
updated.IsActive.Should().BeFalse("update preserves the persisted active state");
}
[Fact]
public async Task Update_WhenAdminRequestsActiveState_ReactivatesService()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var input = Input("Inactive service");
input.IsActive = true;
var updated = await service.UpdateAsync(Admin(), created.Id, input, CancellationToken.None);
updated.IsActive.Should().BeTrue();
ctx.Services.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task Update_WhenSchedulerRequestsActiveState_ThrowsForbidden()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Inactive service"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
var input = Input("Inactive service");
input.IsActive = true;
var act = () => service.UpdateAsync(Scheduler(), created.Id, input, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().IsActive.Should().BeFalse();
}
[Fact]
public async Task Update_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).UpdateAsync(Admin(), 404, Input(), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task Update_WhenDuplicateNameElsewhere_ThrowsDuplicateName()
{
using var ctx = NewContext();
var service = NewService(ctx);
var first = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.CreateAsync(Admin(), Input("Clog"), CancellationToken.None);
var act = () => service.UpdateAsync(Admin(), first.Id, Input("clog"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("DuplicateName");
}
[Fact]
public async Task Update_WhenCallerLacksRole_ThrowsForbidden_BeforeAnyMutation()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.UpdateAsync(Principal("User"), created.Id, Input("Changed"), CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().Name.Should().Be("Leak");
}
[Fact]
public async Task Deactivate_SoftStateChangeOnly_PreservesRow()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
await service.DeactivateAsync(Admin(), created.Id, CancellationToken.None);
ctx.Services.Should().HaveCount(1, "deactivation never hard deletes");
var byId = await service.GetByIdAsync(created.Id, CancellationToken.None);
byId.Should().NotBeNull();
byId!.IsActive.Should().BeFalse();
}
[Fact]
public async Task Deactivate_WhenScheduler_ThrowsForbidden()
{
using var ctx = NewContext();
var service = NewService(ctx);
var created = await service.CreateAsync(Admin(), Input("Leak"), CancellationToken.None);
var act = () => service.DeactivateAsync(Scheduler(), created.Id, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("Forbidden");
ctx.Services.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task Deactivate_WhenNotFound_ThrowsNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).DeactivateAsync(Admin(), 404, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("NotFound");
}
[Fact]
public async Task GetAll_FiltersByActiveStateAndWorkOrderType()
{
using var ctx = NewContext();
var service = NewService(ctx);
var pm = await service.CreateAsync(Admin(), Input("PM Service", types: new List<WorkOrderType> { WorkOrderType.PM }), CancellationToken.None);
var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List<WorkOrderType> { WorkOrderType.Reactive }), CancellationToken.None);
await service.DeactivateAsync(Admin(), reactive.Id, CancellationToken.None);
(await service.GetAllAsync(true, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(false, null, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { reactive.Id });
(await service.GetAllAsync(null, WorkOrderType.PM, CancellationToken.None)).Select(s => s.Id)
.Should().BeEquivalentTo(new[] { pm.Id });
(await service.GetAllAsync(null, WorkOrderType.Emergency, CancellationToken.None))
.Should().BeEmpty();
}
[Fact]
public async Task GetAll_WhenWorkOrderTypeFilterUnsupported_ThrowsWorkOrderTypeInvalid()
{
using var ctx = NewContext();
var act = () => NewService(ctx).GetAllAsync(null, WorkOrderType.Project, CancellationToken.None);
(await act.Should().ThrowAsync<ServicesRegistryValidationException>()).Which.Code.Should().Be("WorkOrderTypeInvalid");
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNull()
{
using var ctx = NewContext();
(await NewService(ctx).GetByIdAsync(99, CancellationToken.None)).Should().BeNull();
}
}