using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; /// /// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289), /// mentions, and decisions on their uplift requests (SH-215). /// public class NotificationActivityFeedTests { private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc); private const string Me = "dispatcher-me"; private const string Other = "dispatcher-other"; private const string Admin = "admin-1"; private sealed class FixedTimeProvider : TimeProvider { public override DateTimeOffset GetUtcNow() => new(Now); } private static ApplicationDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var context = new ApplicationDbContext(options); context.Users.AddRange( new ApplicationUser { Id = Me, FirstName = "Maya", LastName = "Reed" }, new ApplicationUser { Id = Other, FirstName = "Omar", LastName = "Diaz" }, new ApplicationUser { Id = Admin, FirstName = "Ada", LastName = "Stone" }); context.SaveChanges(); return context; } private static NotificationFeedService NewService(ApplicationDbContext context) => new( new NotificationFeedDataService(context), new VendorOperationsDataService(context, new DispatchDataService(context)), new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)), new FixedTimeProvider()); private static ClaimsPrincipal User(int accountId, string role, string userId) => new(new ClaimsIdentity(new[] { new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()), new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Role, role) }, "test")); private static ClaimsPrincipal Dispatcher(int accountId = 1, string userId = Me) => User(accountId, "Dispatcher", userId); private static WorkOrder AddWorkOrder(ApplicationDbContext context, int accountId, string? assignTo, string number, LifecycleStatus status = LifecycleStatus.Pending) { var workOrder = new WorkOrder { AccountId = accountId, AssignTo = assignTo, InternalWONumber = number, LifecycleStatus = status, CreatedDate = Now.AddDays(-30) }; context.workOrders.Add(workOrder); context.SaveChanges(); return workOrder; } private static void Assigned(ApplicationDbContext context, WorkOrder workOrder, string? by, DateTime at, string field = "AssignTo") { context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog { WorkOrderId = workOrder.Id, UserId = by, FieldName = field, OldValue = "", NewValue = workOrder.AssignTo, Action = "AssignmentChanged", CreatedAt = at }); context.SaveChanges(); } private static Comments Comment(ApplicationDbContext context, WorkOrder workOrder, string? by, string text, DateTime at, string? commenter = null) { var comment = new Comments { WorkerOrderId = workOrder.Id, UserId = by, Commenter = commenter, Commenttext = text, CommentType = "General", RecordType = "WorkOrder", CreatedDate = at }; context.Comments.Add(comment); context.SaveChanges(); return comment; } private static DispatchUpliftRequest Uplift(ApplicationDbContext context, WorkOrder workOrder, string requestedBy, string status, decimal amount, string? decidedBy, DateTime? decidedAt, string? note = null) { var dispatch = new Dispatch { WorkOrderId = workOrder.Id, Status = "Sent" }; context.Dispatches.Add(dispatch); context.SaveChanges(); var uplift = new DispatchUpliftRequest { DispatchId = dispatch.Id, RequestedNTE = amount, Status = status, createdby = requestedBy, DecidedAt = decidedAt, DecidedByUserId = decidedBy, DecisionNote = note, CreatedDate = Now.AddDays(-2) }; context.DispatchUpliftRequests.Add(uplift); context.SaveChanges(); return uplift; } private static IReadOnlyList Items(NotificationFeedDto feed, string reason) => feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items ?? Array.Empty(); // ---- SH-288 new assignments ---- [Fact] public async Task Assignment_BySomeoneElse_IsOneLowActivityItemThatOpensTheWorkOrder() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-100"); Assigned(context, workOrder, Other, Now.AddHours(-3)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; item.Id.Should().Be($"new-assigned-{workOrder.Id}"); item.Severity.Should().Be(NotificationSeverities.Low); item.RowType.Should().Be(NotificationRowTypes.Dismissable); item.Title.Should().Be("WO #WO-100 was assigned to you"); item.Count.Should().Be(1); item.TriggeredAt.Should().Be(Now.AddHours(-3)); item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc); item.Target.Kind.Should().Be(NotificationTargetKinds.WorkOrder); item.Target.WorkOrderId.Should().Be(workOrder.Id); item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Info); feed.Sections.Single(s => s.Reason == NotificationReasons.Activity).Label.Should().Be("Recent activity"); } [Fact] public async Task Assignments_MadeInOneAction_BecomeOneGroupedItemThatOpensTheUsersQueue() { using var context = NewContext(); var first = AddWorkOrder(context, 1, Me, "WO-1"); var second = AddWorkOrder(context, 1, Me, "WO-2"); var third = AddWorkOrder(context, 1, Me, "WO-3"); var later = AddWorkOrder(context, 1, Me, "WO-4"); Assigned(context, first, Other, Now.AddHours(-5)); Assigned(context, second, Other, Now.AddHours(-5).AddSeconds(20)); Assigned(context, third, Other, Now.AddHours(-5).AddSeconds(50)); // Same person, an hour later: a separate action. Assigned(context, later, Other, Now.AddHours(-4)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var items = Items(feed, NotificationReasons.Activity); items.Should().HaveCount(2); var grouped = items.Single(i => i.Count == 3); grouped.Id.Should().Be($"new-assigned-group-{first.Id}"); grouped.Title.Should().Be("3 new work orders assigned to you"); grouped.Severity.Should().Be(NotificationSeverities.Low); grouped.Target.Kind.Should().Be(NotificationTargetKinds.Queue); grouped.Target.Queue.Should().Be(NotificationQueues.AssignedToMe); grouped.WorkOrders.Select(w => w.Id).Should().BeEquivalentTo(new[] { first.Id, second.Id, third.Id }); items.Single(i => i.Count == 1).Target.WorkOrderId.Should().Be(later.Id); } [Fact] public async Task Assignment_IsNotShownWhenSelfMadeStaleClosedOrReassignedAway() { using var context = NewContext(); Assigned(context, AddWorkOrder(context, 1, Me, "SELF"), Me, Now.AddHours(-1)); Assigned(context, AddWorkOrder(context, 1, Me, "STALE"), Other, Now.AddDays(-8)); Assigned(context, AddWorkOrder(context, 1, Me, "DONE", LifecycleStatus.Completed), Other, Now.AddHours(-1)); var movedAway = AddWorkOrder(context, 1, Me, "MOVED"); Assigned(context, movedAway, Other, Now.AddHours(-2)); movedAway.AssignTo = Other; Assigned(context, movedAway, Admin, Now.AddHours(-1)); // Reassigned to me earlier, then someone re-saved it to me again recently: the latest entry counts. var reassigned = AddWorkOrder(context, 1, Me, "LATEST"); Assigned(context, reassigned, Other, Now.AddDays(-9)); Assigned(context, reassigned, Admin, Now.AddMinutes(-10)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); Items(feed, NotificationReasons.Activity).Should().ContainSingle() .Which.Target.WorkOrderId.Should().Be(reassigned.Id); } [Fact] public async Task Assignment_CountsLegacyAuditEntriesButNotUnauditedAssignees() { using var context = NewContext(); var legacy = AddWorkOrder(context, 1, Me, "LEGACY"); Assigned(context, legacy, Other, Now.AddDays(-1), field: "AssignedTo"); // Assigned at some point with no audit trail: there is no assignment event to report. var unaudited = AddWorkOrder(context, 1, Me, "UNAUDITED"); unaudited.CreatedDate = Now.AddHours(-2); context.SaveChanges(); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); Items(feed, NotificationReasons.Activity).Should().ContainSingle() .Which.Target.WorkOrderId.Should().Be(legacy.Id); } // ---- SH-289 comments and mentions ---- [Fact] public async Task Comments_BySomeoneElseOnMyWorkOrder_GroupIntoOneMediumItemOnTheCommentsTab() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-7"); var first = Comment(context, workOrder, Other, "Vendor is on site", Now.AddHours(-3)); var second = Comment(context, workOrder, null, "Reply from the email thread", Now.AddHours(-2), commenter: "Site Lead"); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; item.Id.Should().Be($"comments-{workOrder.Id}-{second.Id}"); item.Title.Should().Be("2 new comments on WO #WO-7"); item.Severity.Should().Be(NotificationSeverities.Medium); item.Count.Should().Be(1); // The group starts at its earliest comment; the Comments tab highlights from there. item.TriggeredAt.Should().Be(first.CreatedDate!.Value); item.TriggeredAt.Kind.Should().Be(DateTimeKind.Utc); item.Target.WorkOrderId.Should().Be(workOrder.Id); item.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments); } [Fact] public async Task Comment_Single_ShowsTheCommenterAndAOneLinePreview() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-8"); var text = "Please ask @[" + Admin + ":Ada Stone] about the gate code\nbefore the vendor arrives, because the " + "site closes early on Fridays and security will not let anyone in after four"; Comment(context, workOrder, Other, text, Now.AddHours(-1)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var title = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject.Title; title.Should().StartWith("Omar Diaz commented on WO #WO-8: “Please ask @Ada Stone about the gate code before"); title.Should().EndWith("…”"); NotificationActivityItems.Preview(text).Should().HaveLength(80); } [Fact] public async Task Comments_NeverNotifyTheirAuthorAndClearOnceTheUserReplies() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-9"); Comment(context, workOrder, Me, "My own note", Now.AddHours(-5)); Comment(context, workOrder, Other, "Answered already", Now.AddHours(-4)); Comment(context, workOrder, Me, "Thanks", Now.AddHours(-3)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); Items(feed, NotificationReasons.Activity).Should().BeEmpty(); var unanswered = Comment(context, workOrder, Other, "One more thing", Now.AddHours(-1)); feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var item = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; item.Title.Should().Be("Omar Diaz commented on WO #WO-9: “One more thing”"); item.TriggeredAt.Should().Be(unanswered.CreatedDate!.Value); } [Fact] public async Task Comments_ReachPriorCommentersButNotBystanders() { using var context = NewContext(); // Someone else's work order that I commented on earlier this month. var joined = AddWorkOrder(context, 1, Other, "JOINED"); Comment(context, joined, Me, "Adding context", Now.AddDays(-20)); Comment(context, joined, Other, "Follow-up", Now.AddHours(-2)); var bystander = AddWorkOrder(context, 1, Other, "NOT-MINE"); Comment(context, bystander, Admin, "Internal note", Now.AddHours(-2)); var stale = AddWorkOrder(context, 1, Me, "OLD"); Comment(context, stale, Other, "Last week", Now.AddDays(-8)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); Items(feed, NotificationReasons.Activity).Should().ContainSingle() .Which.Target.WorkOrderId.Should().Be(joined.Id); // The owner of NOT-MINE is notified of the admin's note, and not of their own follow-up. var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None); Items(ownerFeed, NotificationReasons.Activity).Should().ContainSingle() .Which.Target.WorkOrderId.Should().Be(bystander.Id); } [Fact] public async Task Mention_IsAHighMentionsItemAndIsNotAlsoCountedAsAComment() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-11"); Comment(context, workOrder, Other, "Plain update", Now.AddHours(-3)); var mention = Comment(context, workOrder, Other, "@[" + Me + ":Maya Reed] can you confirm?", Now.AddHours(-2)); // A mention reaches the user even on a work order they are not otherwise part of. var elsewhere = AddWorkOrder(context, 1, Other, "WO-12"); Comment(context, elsewhere, Admin, "Looping in @[" + Me + ":Maya Reed]", Now.AddHours(-1)); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var mentions = Items(feed, NotificationReasons.Mentions); mentions.Should().HaveCount(2); var onMine = mentions.Single(i => i.Target.WorkOrderId == workOrder.Id); onMine.Id.Should().Be($"mention-{mention.Id}"); onMine.Title.Should().Be("Omar Diaz mentioned you on WO #WO-11"); onMine.Severity.Should().Be(NotificationSeverities.High); onMine.Target.Tab.Should().Be(NotificationWorkOrderTabs.Comments); onMine.TriggeredAt.Should().Be(mention.CreatedDate!.Value); mentions.Single(i => i.Target.WorkOrderId == elsewhere.Id).Title.Should().Be("Ada Stone mentioned you on WO #WO-12"); var activity = Items(feed, NotificationReasons.Activity).Should().ContainSingle().Subject; activity.Title.Should().Be("Omar Diaz commented on WO #WO-11: “Plain update”"); feed.Sections.Select(s => s.Reason).Should().ContainInOrder(NotificationReasons.Mentions, NotificationReasons.Activity); } [Fact] public void MentionsUser_MatchesOnlyTheUsersOwnToken() { NotificationActivityItems.MentionsUser("hi @[" + Me + ":Maya Reed]", Me).Should().BeTrue(); NotificationActivityItems.MentionsUser("hi @[" + Me + "x:Someone Else]", Me).Should().BeFalse(); NotificationActivityItems.MentionsUser("hi @Maya Reed", Me).Should().BeFalse(); NotificationActivityItems.MentionsUser(null, Me).Should().BeFalse(); } // ---- SH-215 uplift decisions ---- [Fact] public async Task UpliftDecisions_OnMyRequests_ShowAmountAndReasonAndOpenTheUplifts() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Other, "WO-20"); var approved = Uplift(context, workOrder, Me, "Approved", 1250m, Admin, Now.AddHours(-3)); var rejected = Uplift(context, workOrder, Me, "Rejected", 400m, Admin, Now.AddHours(-2), "Quote does not match the scope agreed"); var revoked = Uplift(context, workOrder, Me, "Revoked", 90.5m, Admin, Now.AddHours(-1), "Duplicate of an earlier uplift"); var legacy = Uplift(context, workOrder, Me, "Denied", 75m, Admin, Now.AddHours(-4), "Old wording"); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); var items = Items(feed, NotificationReasons.Activity); items.Should().HaveCount(4); var approvedItem = items.Single(i => i.Id == $"uplift-approved-{approved.Id}"); approvedItem.Title.Should().Be("Your $1,250.00 uplift on WO #WO-20 was approved"); approvedItem.Severity.Should().Be(NotificationSeverities.Medium); approvedItem.Target.Tab.Should().Be(NotificationWorkOrderTabs.Uplifts); approvedItem.Target.WorkOrderId.Should().Be(workOrder.Id); var rejectedItem = items.Single(i => i.Id == $"uplift-rejected-{rejected.Id}"); rejectedItem.Title.Should().Be("Your $400.00 uplift on WO #WO-20 was rejected: “Quote does not match the scope agreed”"); rejectedItem.Severity.Should().Be(NotificationSeverities.High); var revokedItem = items.Single(i => i.Id == $"uplift-revoked-{revoked.Id}"); revokedItem.Title.Should().Be("Your $90.50 uplift on WO #WO-20 was revoked: “Duplicate of an earlier uplift”"); revokedItem.Severity.Should().Be(NotificationSeverities.High); revokedItem.TriggeredAt.Should().Be(Now.AddHours(-1)); items.Single(i => i.Id == $"uplift-rejected-{legacy.Id}").Severity.Should().Be(NotificationSeverities.High); // The requester need not be the dispatcher who owns the work order. var ownerFeed = await NewService(context).GetFeedAsync(Dispatcher(userId: Other), CancellationToken.None); Items(ownerFeed, NotificationReasons.Activity).Should().BeEmpty(); } [Fact] public async Task UpliftDecisions_SkipPendingAutoApprovedSelfRevokedAndStaleRequests() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Me, "WO-21"); Uplift(context, workOrder, Me, "Pending", 100m, null, null); Uplift(context, workOrder, Me, "NoApprovalRequired", 100m, null, null); Uplift(context, workOrder, Me, "Revoked", 100m, Me, Now.AddHours(-1)); Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddDays(-8)); var deleted = Uplift(context, workOrder, Me, "Approved", 100m, Admin, Now.AddHours(-1)); deleted.IsDeleted = true; context.SaveChanges(); var feed = await NewService(context).GetFeedAsync(Dispatcher(), CancellationToken.None); Items(feed, NotificationReasons.Activity).Should().BeEmpty(); } // ---- scope ---- [Fact] public async Task PersonalItems_NeverCrossAccounts() { using var context = NewContext(); var foreign = AddWorkOrder(context, 2, Me, "FOREIGN"); Assigned(context, foreign, Other, Now.AddHours(-1)); Comment(context, foreign, Other, "@[" + Me + ":Maya Reed] hello", Now.AddHours(-1)); Uplift(context, foreign, Me, "Approved", 10m, Admin, Now.AddHours(-1)); var sameUserOtherAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 1), CancellationToken.None); sameUserOtherAccount.Sections.Should().BeEmpty(); var inAccount = await NewService(context).GetFeedAsync(Dispatcher(accountId: 2), CancellationToken.None); Items(inAccount, NotificationReasons.Mentions).Should().ContainSingle(); Items(inAccount, NotificationReasons.Activity).Should().HaveCount(2); } [Fact] public async Task PersonalItems_ReachEveryFeedRoleByUserId() { using var context = NewContext(); var workOrder = AddWorkOrder(context, 1, Other, "WO-30"); Comment(context, workOrder, Other, "@[" + Admin + ":Ada Stone] please approve", Now.AddHours(-1)); var adminFeed = await NewService(context).GetFeedAsync(User(1, "Admin", Admin), CancellationToken.None); Items(adminFeed, NotificationReasons.Mentions).Should().ContainSingle() .Which.Title.Should().Be("Omar Diaz mentioned you on WO #WO-30"); var managerFeed = await NewService(context).GetFeedAsync(User(1, "Manager", "manager-1"), CancellationToken.None); Items(managerFeed, NotificationReasons.Mentions).Should().BeEmpty(); Items(managerFeed, NotificationReasons.Activity).Should().BeEmpty(); } [Fact] public async Task PersonalQueries_ForwardCancellation() { using var context = NewContext(); var data = new NotificationFeedDataService(context); var scope = new NotificationPersonalScope(1, Me); using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); await FluentActions.Awaiting(() => data.GetRecentAssignmentsAsync(scope, Now, 10, cancelled.Token)) .Should().ThrowAsync(); await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token)) .Should().ThrowAsync(); await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token)) .Should().ThrowAsync(); } }