2026-09-18 12:40:33 -03:00
|
|
|
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;
|
2026-09-25 11:16:21 -03:00
|
|
|
using Moq;
|
2026-09-18 12:40:33 -03:00
|
|
|
using SeaHaven.DataServices.Implementation;
|
|
|
|
|
using SeaHaven.Services.DTOs;
|
|
|
|
|
using SeaHaven.Services.Exceptions;
|
|
|
|
|
using SeaHaven.Services.Helpers;
|
|
|
|
|
using SeaHaven.Services.Implementation;
|
2026-09-25 11:16:21 -03:00
|
|
|
using SeaHaven.Services.Interfaces;
|
2026-09-18 12:40:33 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:59:56 -03:00
|
|
|
[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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:40:33 -03:00
|
|
|
[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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:59:56 -03:00
|
|
|
[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:40:33 -03:00
|
|
|
[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();
|
2026-09-25 11:16:21 -03:00
|
|
|
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
2026-09-18 12:40:33 -03:00
|
|
|
{
|
|
|
|
|
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();
|
2026-09-25 11:16:21 -03:00
|
|
|
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
|
2026-09-18 12:40:33 -03:00
|
|
|
{
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|