mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
453 lines
22 KiB
C#
453 lines
22 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Items addressed to the signed-in user: new assignments (SH-288), comments (SH-289),
|
||
|
|
/// mentions, and decisions on their uplift requests (SH-215).
|
||
|
|
/// </summary>
|
||
|
|
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<ApplicationDbContext>()
|
||
|
|
.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<NotificationItemDto> Items(NotificationFeedDto feed, string reason)
|
||
|
|
=> feed.Sections.SingleOrDefault(section => section.Reason == reason)?.Items
|
||
|
|
?? Array.Empty<NotificationItemDto>();
|
||
|
|
|
||
|
|
// ---- 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<OperationCanceledException>();
|
||
|
|
await FluentActions.Awaiting(() => data.GetUnansweredCommentsAsync(scope, Now, 10, cancelled.Token))
|
||
|
|
.Should().ThrowAsync<OperationCanceledException>();
|
||
|
|
await FluentActions.Awaiting(() => data.GetUpliftDecisionsAsync(scope, Now, 10, cancelled.Token))
|
||
|
|
.Should().ThrowAsync<OperationCanceledException>();
|
||
|
|
}
|
||
|
|
}
|