shoc-backend/Api.SeaHavenIndustries.Tests/NotificationFeedServiceTests.cs

591 lines
27 KiB
C#
Raw Permalink Normal View History

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();
}
}