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() .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? types = null) => new() { Name = name, Trade = trade, IconKey = iconKey, RequiresCompletionDocument = requiresDoc, CompletionDocTemplateId = templateId, SupportedWorkOrderTypes = types ?? new List { 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()).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()).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()).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()).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()).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()).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()).Which.Code.Should().Be("Forbidden"); } [Fact] public async Task Create_WhenTemplateMissing_Inactive_OrDeleted_ThrowsTemplateInvalid() { using var ctx = NewContext(); (await new Func>(() => NewService(ctx).CreateAsync( Admin(), Input(requiresDoc: true, templateId: 999), CancellationToken.None)) .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); ctx.CompletionDocTemplates.Add(Template("Inactive", active: false)); ctx.CompletionDocTemplates.Add(Template("Deleted", deleted: true)); ctx.SaveChanges(); (await new Func>(() => NewService(ctx).CreateAsync( Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Inactive").Id), CancellationToken.None)) .Should().ThrowAsync()).Which.Code.Should().Be("TemplateInvalid"); (await new Func>(() => NewService(ctx).CreateAsync( Admin(), Input(requiresDoc: true, templateId: ctx.CompletionDocTemplates.Local.First(t => t.Name == "Deleted").Id), CancellationToken.None)) .Should().ThrowAsync()).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.PM, unsupported }); var act = () => NewService(ctx).CreateAsync(Admin(), input, CancellationToken.None); (await act.Should().ThrowAsync()).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()).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()).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()).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()).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()).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()).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.PM }), CancellationToken.None); var reactive = await service.CreateAsync(Admin(), Input(name: "Reactive Service", trade: "HVAC", iconKey: "hvac", types: new List { 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()).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(); } }