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() .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().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)[] { (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()), (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(); 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()).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()).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(); } [Fact] public async Task Controller_MapsForbiddenScopeTo403() { using var context = NewContext(); var controller = new NotificationsController(NewService(context), Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = User(1, "Vendor") } } }; var result = await controller.GetFeed(CancellationToken.None); result.Should().BeOfType().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()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = User(1, "Admin") } } }; var result = await controller.GetFeed(CancellationToken.None); var feed = result.Should().BeOfType().Which.Value.Should().BeOfType().Subject; feed.Sections.Single().Reason.Should().Be(NotificationReasons.Unassigned); } [Fact] public void Controller_RequiresAnAuthenticatedCaller() { typeof(NotificationsController).GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Should().NotBeEmpty(); } }