mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
364 lines
16 KiB
C#
364 lines
16 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_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();
|
|
}
|
|
}
|