shoc-backend/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs
Alexandre Brandizzi 14c8e46dd0 feat(notifications): SEV response-window alerts and breach acknowledgement
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.
2026-09-25 11:16:21 -03:00

590 lines
27 KiB
C#

using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
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;
public class NotificationFeedServiceTests
{
// 12:00 in New York, so the business date is 2026-09-18.
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Today = new(2026, 9, 18);
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 NotificationFeedService NewService(ApplicationDbContext context)
{
var resolver = new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context));
return new NotificationFeedService(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
resolver,
new FixedTimeProvider());
}
private static ClaimsPrincipal User(int accountId, string role, string userId = "user-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static ClaimsPrincipal OrgWideAdmin()
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Open(int accountId, string? assignTo = null, DateTime? scheduled = null, string? number = null)
=> new()
{
AccountId = accountId,
AssignTo = assignTo,
ScheduledDate = scheduled,
InternalWONumber = number,
LifecycleStatus = LifecycleStatus.Scheduled,
CreatedDate = Now.AddDays(-3)
};
private static WorkOrder AvetaDue(int accountId, string assignTo, DateTime scheduled, string number)
{
var workOrder = Open(accountId, assignTo, scheduled, number);
workOrder.AvetaRequired = true;
return workOrder;
}
private static NotificationSectionDto Section(NotificationFeedDto feed, string reason)
=> feed.Sections.Single(section => section.Reason == reason);
private static void AddActiveDispatch(ApplicationDbContext context, WorkOrder workOrder)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
context.Vendors.Add(vendor);
context.SaveChanges();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledDate
});
context.SaveChanges();
}
[Fact]
public async Task Unassigned_GroupsEveryOpenUnassignedWorkOrderIntoOneHighItem()
{
using var context = NewContext();
var newest = Open(1);
newest.CreatedDate = Now.AddHours(-1);
var completed = Open(1);
completed.LifecycleStatus = LifecycleStatus.Completed;
var canceled = Open(1);
canceled.LifecycleStatus = LifecycleStatus.Canceled;
var legacyDone = Open(1);
legacyDone.LifecycleStatus = null;
legacyDone.Status = "Done";
var template = Open(1);
template.istemplate = true;
var deleted = Open(1);
deleted.IsDeleted = true;
context.workOrders.AddRange(
Open(1), Open(1, assignTo: ""), newest, Open(1, assignTo: "dispatcher-1"),
completed, canceled, legacyDone, template, deleted);
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
var section = Section(feed, NotificationReasons.Unassigned);
section.Label.Should().Be("Unassigned");
section.Count.Should().Be(3);
var item = section.Items.Should().ContainSingle().Subject;
item.Id.Should().Be("unassigned");
item.Severity.Should().Be(NotificationSeverities.High);
item.RowType.Should().Be(NotificationRowTypes.Dismissable);
item.Title.Should().Be("3 work orders are unassigned");
item.Count.Should().Be(3);
item.TriggeredAt.Should().Be(newest.CreatedDate);
item.Target.Kind.Should().Be(NotificationTargetKinds.Queue);
item.Target.Queue.Should().Be(NotificationQueues.Unassigned);
}
[Fact]
public async Task Unassigned_CountFollowsAssignmentAndSectionDisappearsAtZero()
{
using var context = NewContext();
var first = Open(1);
var second = Open(1);
context.workOrders.AddRange(first, second);
await context.SaveChangesAsync();
var service = NewService(context);
first.AssignTo = "dispatcher-1";
await context.SaveChangesAsync();
var afterOne = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
var item = Section(afterOne, NotificationReasons.Unassigned).Items.Single();
item.Title.Should().Be("1 work order is unassigned");
item.Id.Should().Be("unassigned", "the id stays stable so a session dismissal survives count changes");
second.AssignTo = "dispatcher-2";
await context.SaveChangesAsync();
var afterAll = await service.GetFeedAsync(User(1, "Admin"), CancellationToken.None);
afterAll.Sections.Should().NotContain(section => section.Reason == NotificationReasons.Unassigned);
}
[Theory]
[InlineData("Admin")]
[InlineData("Manager")]
[InlineData("Scheduler")]
public async Task Unassigned_IsShownToRolesThatSeeEveryDispatcher(string role)
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, role), CancellationToken.None);
Section(feed, NotificationReasons.Unassigned).Count.Should().Be(1);
}
[Fact]
public async Task Unassigned_IsNotShownToDispatchers()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
feed.Sections.Should().BeEmpty();
}
[Fact]
public async Task AvetaMissing_OnlyFlagsOpenRequiredWorkOrdersInTheOneDayWindowWithoutTheDocument()
{
using var context = NewContext();
var dueToday = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-TODAY");
var dueTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(1).AddHours(9), "A-TOMORROW");
var withDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(10), "A-HAS-DOC");
var withDeletedDocument = AvetaDue(1, "dispatcher-1", Today.AddHours(11), "A-DELETED-DOC");
var dayAfterTomorrow = AvetaDue(1, "dispatcher-1", Today.AddDays(2).AddHours(9), "A-LATER");
var yesterday = AvetaDue(1, "dispatcher-1", Today.AddDays(-1).AddHours(9), "A-PAST");
var completed = AvetaDue(1, "dispatcher-1", Today.AddHours(12), "A-DONE");
completed.LifecycleStatus = LifecycleStatus.Completed;
var notRequired = Open(1, "dispatcher-1", Today.AddHours(13), "A-NOT-REQUIRED");
context.workOrders.AddRange(
dueToday, dueTomorrow, withDocument, withDeletedDocument, dayAfterTomorrow, yesterday, completed, notRequired);
await context.SaveChangesAsync();
context.workOrderAttachments.AddRange(
new WorkOrderAttachments { WorkorderId = withDocument.Id, Category = WorkOrderMediaCategory.Aveta },
new WorkOrderAttachments { WorkorderId = withDeletedDocument.Id, Category = WorkOrderMediaCategory.Aveta, IsDeleted = true },
new WorkOrderAttachments { WorkorderId = dueToday.Id, Category = WorkOrderMediaCategory.Extra });
await context.SaveChangesAsync();
AddActiveDispatch(context, dueToday);
AddActiveDispatch(context, dueTomorrow);
AddActiveDispatch(context, withDeletedDocument);
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.AvetaMissing);
section.Label.Should().Be("Aveta Missing");
section.Count.Should().Be(3);
section.Items.Select(item => item.WorkOrders.Single().Number)
.Should().Equal("A-TOMORROW", "A-TODAY", "A-DELETED-DOC");
var tomorrowItem = section.Items[0];
tomorrowItem.Id.Should().Be($"aveta-missing-{dueTomorrow.Id}");
tomorrowItem.Severity.Should().Be(NotificationSeverities.Medium);
tomorrowItem.Title.Should().Be("WO #A-TOMORROW is missing the Aveta document");
tomorrowItem.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder);
tomorrowItem.Target.WorkOrderId.Should().Be(dueTomorrow.Id);
tomorrowItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Extras);
}
[Fact]
public async Task AvetaMissing_ClearsOnceTheDocumentIsAttached()
{
using var context = NewContext();
var workOrder = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "A-1");
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
AddActiveDispatch(context, workOrder);
var service = NewService(context);
var dispatcher = User(1, "Dispatcher", "dispatcher-1");
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections
.Should().ContainSingle(section => section.Reason == NotificationReasons.AvetaMissing);
context.workOrderAttachments.Add(new WorkOrderAttachments { WorkorderId = workOrder.Id, Category = WorkOrderMediaCategory.Aveta });
await context.SaveChangesAsync();
(await service.GetFeedAsync(dispatcher, CancellationToken.None)).Sections.Should().BeEmpty();
}
[Fact]
public async Task NoVendor_MatchesTheVendorReminderRuleWithinFortyEightHours()
{
using var context = NewContext();
var noVendor = Open(1, "dispatcher-1", Now.AddHours(24), "NV-1");
var withDispatch = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DISPATCHED");
var withLinkedDispatch = Open(1, "dispatcher-1", Now.AddHours(22), "NV-LINKED");
var withFinishedDispatch = Open(1, "dispatcher-1", Now.AddHours(30), "NV-FINISHED");
var tooFar = Open(1, "dispatcher-1", Now.AddHours(72), "NV-LATER");
context.workOrders.AddRange(noVendor, withDispatch, withLinkedDispatch, withFinishedDispatch, tooFar);
await context.SaveChangesAsync();
AddActiveDispatch(context, withDispatch);
var vendor = new Vendor { CompanyName = "Linked", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var multi = new Dispatch { VendorId = vendor.Id, WorkOrderId = withDispatch.Id, Status = "Sent" };
var finished = new Dispatch { VendorId = vendor.Id, WorkOrderId = withFinishedDispatch.Id, Status = "Completed" };
context.Dispatches.AddRange(multi, finished);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = multi.Id, WorkOrderId = withLinkedDispatch.Id });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.NoVendor);
section.Label.Should().Be("No Vendor");
section.Items.Select(item => item.WorkOrders.Single().Number).Should().Equal("NV-FINISHED", "NV-1");
var item = section.Items.Single(row => row.Id == $"no-vendor-{noVendor.Id}");
item.Severity.Should().Be(NotificationSeverities.Medium);
item.Title.Should().Be("WO #NV-1 starts within 48h with no vendor assigned");
item.TriggeredAt.Should().Be(noVendor.ScheduledDate!.Value.AddHours(-48));
item.Target.WorkOrderId.Should().Be(noVendor.Id);
}
[Fact]
public async Task NoVendor_FlagsTheSameWorkOrdersAsTheVendorOperationsReminders()
{
using var context = NewContext();
var first = Open(1, "dispatcher-1", Now.AddHours(10), "P-1");
var second = Open(2, "dispatcher-2", Now.AddHours(40), "P-2");
var dispatched = Open(1, "dispatcher-1", Now.AddHours(12), "P-3");
context.workOrders.AddRange(first, second, dispatched);
await context.SaveChangesAsync();
AddActiveDispatch(context, dispatched);
var vendorService = new VendorOperationsService(
new VendorOperationsDataService(context, new DispatchDataService(context)), new FixedTimeProvider());
var reminders = await vendorService.GetNotificationsAsync(CancellationToken.None);
var feed = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
var reminderIds = reminders.Items.OfType<NoVendorNotification>().Select(item => item.Id);
Section(feed, NotificationReasons.NoVendor).Items.Select(item => item.Id)
.Should().BeEquivalentTo(reminderIds);
}
[Fact]
public async Task NoVendor_TreatsSoftDeletedDispatchesAsNoVendor()
{
using var context = NewContext();
var deletedDirect = Open(1, "dispatcher-1", Now.AddHours(20), "NV-DEL-DIRECT");
var deletedLinked = Open(1, "dispatcher-1", Now.AddHours(22), "NV-DEL-LINKED");
context.workOrders.AddRange(deletedDirect, deletedLinked);
await context.SaveChangesAsync();
var vendor = new Vendor { CompanyName = "Removed", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var direct = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
var linked = new Dispatch { VendorId = vendor.Id, WorkOrderId = deletedDirect.Id, Status = "Sent", IsDeleted = true };
context.Dispatches.AddRange(direct, linked);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder { DispatchId = linked.Id, WorkOrderId = deletedLinked.Id });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
Section(feed, NotificationReasons.NoVendor).Items
.Select(item => item.WorkOrders.Single().Number)
.Should().BeEquivalentTo("NV-DEL-DIRECT", "NV-DEL-LINKED");
}
[Fact]
public async Task Sections_OrderBySeverityThenFixedReasonOrder_AndItemsBySeverityThenRecency()
{
using var context = NewContext();
var aveta = AvetaDue(1, "dispatcher-1", Today.AddHours(9), "AV-1");
var earlierNoVendor = Open(1, "dispatcher-1", Now.AddHours(5), "NV-EARLY");
var laterNoVendor = Open(1, "dispatcher-1", Now.AddHours(30), "NV-LATE");
context.workOrders.AddRange(aveta, earlierNoVendor, laterNoVendor, Open(1));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
feed.GeneratedAt.Should().Be(Now);
feed.Sections.Select(section => section.Reason).Should().Equal(
NotificationReasons.Unassigned, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
feed.Sections.Select(section => section.Severity).Should().Equal(
NotificationSeverities.High, NotificationSeverities.Medium, NotificationSeverities.Medium);
var noVendorNumbers = Section(feed, NotificationReasons.NoVendor).Items
.Select(item => item.WorkOrders.Single().Number);
noVendorNumbers.Should().ContainInOrder("NV-LATE", "NV-EARLY");
}
[Fact]
public void Ordering_BreaksEqualSeverityTiesByReasonOrderAndPutsHigherSeverityFirst()
{
static NotificationItemDto Item(string id, string reason, string severity, int minutes) => new()
{
Id = id,
Reason = reason,
Severity = severity,
Count = 1,
TriggeredAt = Now.AddMinutes(minutes)
};
var sections = NotificationFeedOrdering.Order(new (string, int, IReadOnlyList<NotificationItemDto>)[]
{
(NotificationReasons.AvetaMissing, 1, new[] { Item("a", NotificationReasons.AvetaMissing, NotificationSeverities.Medium, 0) }),
(NotificationReasons.Activity, 2, new[]
{
Item("old-low", NotificationReasons.Activity, NotificationSeverities.Low, -10),
Item("new-low", NotificationReasons.Activity, NotificationSeverities.Low, 10),
Item("high", NotificationReasons.Activity, NotificationSeverities.High, -20)
}),
(NotificationReasons.Unassigned, 0, Array.Empty<NotificationItemDto>()),
(NotificationReasons.NoVendor, 1, new[] { Item("n", NotificationReasons.NoVendor, NotificationSeverities.Medium, 0) })
});
sections.Select(section => section.Reason).Should().Equal(
NotificationReasons.Activity, NotificationReasons.NoVendor, NotificationReasons.AvetaMissing);
sections[0].Items.Select(item => item.Id).Should().Equal("high", "new-low", "old-low");
sections[0].Label.Should().Be("Recent activity");
}
[Fact]
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders()
{
using var context = NewContext();
context.workOrders.AddRange(
AvetaDue(1, "dispatcher-1", Today.AddHours(9), "MINE"),
AvetaDue(1, "dispatcher-2", Today.AddHours(9), "THEIRS"),
Open(1, "dispatcher-2", Now.AddHours(5), "THEIRS-NV"));
await context.SaveChangesAsync();
foreach (var workOrder in context.workOrders.Where(w => w.AvetaRequired).ToList())
AddActiveDispatch(context, workOrder);
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
feed.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.AvetaMissing);
Section(feed, NotificationReasons.AvetaMissing).Items.Single().WorkOrders.Single().Number.Should().Be("MINE");
}
[Fact]
public async Task CrossTenant_AccountUserNeverSeesAnotherAccountsWorkOrders()
{
using var context = NewContext();
var otherAveta = AvetaDue(2, "dispatcher-1", Today.AddHours(9), "OTHER-AV");
var otherNoVendor = Open(2, "dispatcher-1", Now.AddHours(5), "OTHER-NV");
var otherUnassigned = Open(2);
var ownUnassigned = Open(1);
context.workOrders.AddRange(otherAveta, otherNoVendor, otherUnassigned, ownUnassigned);
await context.SaveChangesAsync();
AddActiveDispatch(context, otherAveta);
var vendor = context.Vendors.First();
var overlapping = Open(2, "dispatcher-1", Now.AddHours(1), "OTHER-OVERLAP");
context.workOrders.Add(overlapping);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = overlapping.Id, Status = "Sent", ScheduledDate = otherAveta.ScheduledDate });
await context.SaveChangesAsync();
var admin = await NewService(context).GetFeedAsync(User(1, "Admin"), CancellationToken.None);
var dispatcher = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
admin.Sections.Select(section => section.Reason).Should().Equal(NotificationReasons.Unassigned);
Section(admin, NotificationReasons.Unassigned).Count.Should().Be(1);
dispatcher.Sections.Should().BeEmpty();
var orgWide = await NewService(context).GetFeedAsync(OrgWideAdmin(), CancellationToken.None);
orgWide.Sections.Select(section => section.Reason).Should().Contain(new[]
{
NotificationReasons.Unassigned, NotificationReasons.NoVendor,
NotificationReasons.AvetaMissing, NotificationReasons.VendorConflict
});
}
[Fact]
public async Task VendorConflict_KeepsOverlappingAppointmentsAsTheirOwnSection()
{
using var context = NewContext();
var mine = Open(1, "dispatcher-1", Now.AddDays(3), "C-MINE");
var theirs = Open(1, "dispatcher-2", Now.AddDays(3), "C-THEIRS");
context.workOrders.AddRange(mine, theirs);
await context.SaveChangesAsync();
AddActiveDispatch(context, mine);
var vendor = context.Vendors.Single();
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = theirs.Id, Status = "Sent", ScheduledDate = theirs.ScheduledDate });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var unrelated = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-3"), CancellationToken.None);
var section = Section(feed, NotificationReasons.VendorConflict);
section.Label.Should().Be("Vendor Conflict");
section.Count.Should().Be(2);
var item = section.Items.Single();
item.Title.Should().Be("Gateway has overlapping appointments");
item.WorkOrders.Select(workOrder => workOrder.Number).Should().BeEquivalentTo("C-MINE", "C-THEIRS");
unrelated.Sections.Should().BeEmpty();
}
[Fact]
public async Task VendorConflict_CapsByRecencyKeepingNewerOverlaps()
{
using var context = NewContext();
// One vendor whose older cluster produces more overlapping pairs than the cap, plus a
// single newer overlap. Build sweeps a vendor's windows in start order, so the newer pair
// is emitted last and is exactly what an unsorted Take(limit) would drop.
var oldStart = Now.AddDays(3);
var oldOrders = new List<WorkOrder>();
for (var index = 0; index < 11; index++)
oldOrders.Add(Open(1, "dispatcher-1", oldStart, $"OLD-{index}"));
var newerLeft = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-A");
var newerRight = Open(1, "dispatcher-1", Now.AddDays(10), "NEW-B");
context.workOrders.AddRange(oldOrders);
context.workOrders.AddRange(newerLeft, newerRight);
await context.SaveChangesAsync();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
foreach (var workOrder in oldOrders.Append(newerLeft).Append(newerRight))
context.Dispatches.Add(new Dispatch { VendorId = vendor.Id, WorkOrderId = workOrder.Id, Status = "Sent", ScheduledDate = workOrder.ScheduledDate });
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Scheduler"), CancellationToken.None);
var section = Section(feed, NotificationReasons.VendorConflict);
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
section.Items.Should().Contain(item =>
item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-A")
&& item.WorkOrders.Any(workOrder => workOrder.Number == "NEW-B"));
}
[Fact]
public async Task PerWorkOrderSections_AreBoundedButCountEveryWorkOrder()
{
using var context = NewContext();
var total = NotificationFeedService.SectionItemLimit + 5;
for (var index = 0; index < total; index++)
context.workOrders.Add(Open(1, "dispatcher-1", Now.AddHours(1).AddMinutes(index), $"B-{index}"));
await context.SaveChangesAsync();
var feed = await NewService(context).GetFeedAsync(User(1, "Dispatcher", "dispatcher-1"), CancellationToken.None);
var section = Section(feed, NotificationReasons.NoVendor);
section.Count.Should().Be(total);
section.Items.Should().HaveCount(NotificationFeedService.SectionItemLimit);
}
[Fact]
public async Task MissingAccountScope_FailsClosed()
{
using var context = NewContext();
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
var act = () => NewService(context).GetFeedAsync(user, CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task UnknownRole_FailsClosed()
{
using var context = NewContext();
var act = () => NewService(context).GetFeedAsync(User(1, "Vendor"), CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Fact]
public async Task Cancellation_ReachesTheFeedQueries()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => NewService(context).GetFeedAsync(User(1, "Admin"), cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Vendor") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
result.Should().BeOfType<ObjectResult>().Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
}
[Fact]
public async Task Controller_ReturnsTheFeedForAnAuthorizedUser()
{
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(1, "Admin") }
}
};
var result = await controller.GetFeed(CancellationToken.None);
var feed = result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeOfType<NotificationFeedDto>().Subject;
feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned);
}
[Fact]
public void Controller_RequiresAnAuthenticatedCaller()
{
typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
}