using System.Security.Claims; using Api.SeaHavenIndustries.Controllers; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; /// /// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a /// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes). /// public class NotificationSlaTests { private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); private sealed class FixedTimeProvider : TimeProvider { public override DateTimeOffset GetUtcNow() => new(Now); } private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } private static WorkOrderAccountResolver Resolver(ApplicationDbContext context) => new(new AccountDataService(context), new LocationDataService(context)); private static NotificationFeedService NewFeed(ApplicationDbContext context) => new( new NotificationFeedDataService(context), new VendorOperationsDataService(context, new DispatchDataService(context)), Resolver(context), new FixedTimeProvider()); private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context) => new( new NotificationFeedDataService(context), new WorkOrderAuditDataService(context), new UserDataService(context), Resolver(context), new FixedTimeProvider()); private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1") => new(new ClaimsIdentity(new[] { new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Role, role) }, "test")); private static WorkOrder Sla( int id, TimeSpan age, string? severity = "1", WorkOrderType? type = WorkOrderType.Reactive, int accountId = 1, string? assignTo = "disp-1", DateTime? scheduled = null, LifecycleStatus status = LifecycleStatus.Scheduled) => new() { Id = id, AccountId = accountId, AssignTo = assignTo, InternalWONumber = $"{1000 + id}", WorkOrderType = type, Severity = severity, CreatedDate = Now - age, ScheduledDate = scheduled, LifecycleStatus = status }; private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity]; private static NotificationSectionDto? SlaSection(NotificationFeedDto feed) => feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla); private static IEnumerable ItemIds(NotificationFeedDto feed) => SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty(); [Fact] public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge() { SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes) .Should().Equal(new Dictionary { [1] = 120, [2] = 240, [3] = 480, [4] = 1440, [5] = 4320 }); } [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity) { using var context = NewContext(); var second = TimeSpan.FromSeconds(1); var window = Window(severity); var level = severity.ToString(); context.workOrders.AddRange( Sla(1, window / 2 - second, level), Sla(2, window / 2, level), Sla(3, window / 2 + second, level), Sla(4, window - second, level), Sla(5, window, level), Sla(6, window + second, level)); await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 }); ItemIds(feed).Should().BeEquivalentTo( "sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6"); var section = SlaSection(feed)!; section.Label.Should().Be("SLA at Risk"); section.Count.Should().Be(5); section.Severity.Should().Be(NotificationSeverities.Critical); section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item => item.RowType == NotificationRowTypes.Acknowledge && item.Severity == NotificationSeverities.Critical && item.Title.EndsWith("missed its response deadline")); section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item => item.RowType == NotificationRowTypes.Dismissable && item.Severity == NotificationSeverities.High && item.Title.EndsWith("is at risk of missing its response deadline")); } [Fact] public async Task AtRisk_CarriesTheServerComputedClock() { using var context = NewContext(); context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1")); await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject; atRisk.Number.Should().Be("1007"); atRisk.Severity.Should().Be(1); atRisk.StartedAt.Should().Be(Now.AddMinutes(-90)); atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30)); atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc); atRisk.PercentElapsed.Should().Be(75); SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline"); } [Fact] public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked() { using var context = NewContext(); var late = Window(1) * 2; context.workOrders.AddRange( Sla(1, late, "1", WorkOrderType.Reactive), Sla(2, late, "2", WorkOrderType.Emergency), Sla(3, late, "1", WorkOrderType.PM), Sla(4, late, "1", WorkOrderType.Inspection), Sla(5, late, "1", type: null), Sla(6, late, severity: null), Sla(7, late, severity: "9"), Sla(8, late, "1", status: LifecycleStatus.Completed), Sla(9, late, "1", status: LifecycleStatus.Canceled)); await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); } [Fact] public async Task TheClockStartsAtCreation_WhateverTheScheduledDate() { using var context = NewContext(); context.workOrders.AddRange( Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null), // Scheduled next week, but logged three days ago: the deadline is long gone. Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)), // Scheduled days ago, but logged ten minutes ago: nothing is due yet. Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5))); await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2"); feed.SlaAtRisk.Should().BeEmpty(); } [Fact] public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary() { using var context = NewContext(); var atRisk = Window(2) * 3 / 4; context.workOrders.AddRange( Sla(1, atRisk, "2", assignTo: "disp-1"), Sla(2, atRisk, "2", assignTo: "disp-2"), Sla(3, atRisk, "2", assignTo: null), Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2)); await context.SaveChangesAsync(); var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None); var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None); var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None); dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1); ItemIds(dispatcher).Should().Equal("sla-at-risk-1"); admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 }); otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4); } [Fact] public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow() { using var context = NewContext(); for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++) context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1")); context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5")); await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None); var section = SlaSection(feed)!; section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11); section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge) .Should().Be(NotificationFeedService.SectionItemLimit); section.Items.Should().Contain(item => item.Id == "sla-at-risk-500"); // The newest breaches are kept. section.Items.Should().Contain(item => item.Id == "sla-breach-1"); section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}"); } [Fact] public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow() { using var context = NewContext(); context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" }); context.workOrders.AddRange( Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"), Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1")); await context.SaveChangesAsync(); var user = User(1, "Dispatcher", "disp-1"); var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); var audit = await context.WorkOrderAuditLogs.SingleAsync(); audit.WorkOrderId.Should().Be(1); audit.UserId.Should().Be("disp-1"); audit.CreatedAt.Should().Be(Now); audit.Action.Should().Be("SLA breach acknowledged by Jane Doe"); audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName); audit.NewValue.Should().Be("1"); audit.EventType.Should().Be("system"); var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); ItemIds(feed).Should().Equal("sla-breach-2"); SlaSection(feed)!.Count.Should().Be(1); var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged); (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1); } [Fact] public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached() { using var context = NewContext(); var workOrder = Sla(1, TimeSpan.FromHours(30), "2"); context.workOrders.Add(workOrder); await context.SaveChangesAsync(); var user = User(1, "Dispatcher", "disp-1"); await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None); workOrder.Severity = "4"; await context.SaveChangesAsync(); var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None); ItemIds(feed).Should().Equal("sla-breach-1"); } [Fact] public async Task Acknowledge_IsRefusedOutsideTheCallersFeed() { using var context = NewContext(); var late = Window(1) * 2; context.workOrders.AddRange( Sla(1, late, "1", accountId: 2, assignTo: "disp-1"), Sla(2, late, "1", assignTo: "disp-2"), Sla(3, late, "1", status: LifecycleStatus.Completed), Sla(4, late, "1", WorkOrderType.PM)); await context.SaveChangesAsync(); var service = NewAcknowledgement(context); var dispatcher = User(1, "Dispatcher", "disp-1"); (await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); (await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); (await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); (await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); (await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.NotFound); (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); var otherAccountAdmin = User(2, "Admin", "admin-2"); (await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None)) .Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged); } [Fact] public async Task Acknowledge_BeforeTheDeadlineIsRefused() { using var context = NewContext(); context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1")); await context.SaveChangesAsync(); var outcome = await NewAcknowledgement(context) .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None); outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached); (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); } [Fact] public async Task Acknowledge_WithoutANotificationRole_FailsClosed() { using var context = NewContext(); context.workOrders.Add(Sla(1, Window(1) * 2, "1")); await context.SaveChangesAsync(); var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None); (await act.Should().ThrowAsync()).Which.Code.Should().Be("Forbidden"); } [Theory] [InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)] [InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)] [InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)] [InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)] public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus) { using var context = NewContext(); context.workOrders.AddRange( Sla(1, Window(1) * 2, "1"), Sla(2, Window(1) * 3 / 4, "1")); await context.SaveChangesAsync(); var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context)) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") } } }; var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None); result.Should().BeAssignableTo() .Which.StatusCode.Should().Be(expectedStatus); } [Fact] public async Task Acknowledge_ForwardsCancellation() { using var context = NewContext(); context.workOrders.Add(Sla(1, Window(1) * 2, "1")); await context.SaveChangesAsync(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var act = () => NewAcknowledgement(context) .AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token); await act.Should().ThrowAsync(); (await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0); } }