mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Reactive/Emergency work orders with a SEV 1-5 level are timed from their
creation against the SEV Respond deadline (2/4/8/24/72 hours, one backend
table). From 50% they are at risk: a dismissable High row in the "SLA at
Risk" section and an entry in the feed's slaAtRisk set with the server
clock (start, deadline, percent) for the banner and toast. From 100% they
are a Critical acknowledge row that only acknowledging removes.
POST /api/notifications/sla/{id}/acknowledge records who and when as a
work-order audit entry ("SLA breach acknowledged by <name>"), scoped to the
caller's feed audience: 404 outside it, 409 before the deadline, 204 when
recorded or already recorded. A later severity change is a new breach.
402 lines
17 KiB
C#
402 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<ApplicationDbContext>()
|
|
.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<string> ItemIds(NotificationFeedDto feed)
|
|
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
|
|
|
|
[Fact]
|
|
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
|
|
{
|
|
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
|
|
.Should().Equal(new Dictionary<int, double>
|
|
{
|
|
[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<WorkOrderBoardValidationException>()).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<IStatusCodeActionResult>()
|
|
.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<OperationCanceledException>();
|
|
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
|
|
}
|
|
}
|