mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-04 14:52:10 +00:00
The route contract keeps the poc endpoint from main and the uplift evidence routes from this branch. Create still passes the scanned evidence document through the locked mutation.
278 lines
11 KiB
C#
278 lines
11 KiB
C#
using System.Security.Claims;
|
|
using Api.SeaHavenIndustries.Controllers;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Implementation;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Interfaces;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
/// <summary>
|
|
/// Revoke rules exercised end to end through both revoke endpoints (the Uplift
|
|
/// Approvals route and the work-order route) against the real services, so a
|
|
/// refusal is observed as the HTTP result and the unchanged stored state.
|
|
/// </summary>
|
|
public sealed class UpliftRevokeEndpointRulesTests
|
|
{
|
|
private const int WorkOrderId = 1;
|
|
private const int DispatchId = 10;
|
|
private const int AutoApprovedId = 100;
|
|
private const int AdminApprovedId = 101;
|
|
|
|
public enum RevokeRoute
|
|
{
|
|
UpliftApprovals,
|
|
WorkOrder,
|
|
}
|
|
|
|
private static ApplicationDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new ApplicationDbContext(options);
|
|
}
|
|
|
|
private static ClaimsPrincipal OrgWideUser(string userId, string role) =>
|
|
new(new ClaimsIdentity(
|
|
new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
|
new Claim(ClaimTypes.Role, role),
|
|
new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll),
|
|
},
|
|
"test"));
|
|
|
|
private static async Task SeedAsync(ApplicationDbContext context)
|
|
{
|
|
context.Accounts.Add(new Accounts { Id = 1, Name = "Acme Corp", IsDeleted = false });
|
|
context.Users.Add(new ApplicationUser { Id = "admin-1", UserName = "admin-1", FirstName = "Ada", LastName = "Admin" });
|
|
context.Users.Add(new ApplicationUser { Id = "dispatcher-1", UserName = "dispatcher-1", FirstName = "Dee", LastName = "Dispatcher" });
|
|
context.Vendors.Add(new Vendor { Id = 1, CompanyName = "Acme HVAC" });
|
|
context.Dispatches.Add(new Dispatch
|
|
{
|
|
Id = DispatchId,
|
|
VendorId = 1,
|
|
WorkOrderId = WorkOrderId,
|
|
NTEAmount = 2200m,
|
|
DispatchNumber = "DIS-10",
|
|
Status = "Scheduled",
|
|
});
|
|
context.workOrders.Add(new WorkOrder
|
|
{
|
|
Id = WorkOrderId,
|
|
InternalWONumber = "10000000001",
|
|
PrimaryDispatchId = DispatchId,
|
|
AccountId = 1,
|
|
WorkOrderType = WorkOrderType.PM,
|
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
|
});
|
|
// The admin filed this one themselves and it auto-approved within the allowance.
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
Id = AutoApprovedId,
|
|
DispatchId = DispatchId,
|
|
CurrentNTE = 1000m,
|
|
RequestedNTE = 400m,
|
|
Status = UpliftStatus.NoApprovalRequired,
|
|
RequiredTier = 0,
|
|
NotificationStatus = "Sent",
|
|
createdby = "admin-1",
|
|
CreatedDate = DateTime.UtcNow.AddHours(-2),
|
|
});
|
|
context.DispatchUpliftRequests.Add(new DispatchUpliftRequest
|
|
{
|
|
Id = AdminApprovedId,
|
|
DispatchId = DispatchId,
|
|
CurrentNTE = 1400m,
|
|
RequestedNTE = 800m,
|
|
Status = UpliftStatus.Approved,
|
|
RequiredTier = 1,
|
|
NotificationStatus = "Sent",
|
|
createdby = "dispatcher-1",
|
|
CreatedDate = DateTime.UtcNow.AddHours(-1),
|
|
DecidedAt = DateTime.UtcNow.AddMinutes(-30),
|
|
DecidedByUserId = "admin-1",
|
|
});
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
private static WorkOrderUpliftService NewWorkOrderUpliftService(ApplicationDbContext context) =>
|
|
new(
|
|
new UpliftDataService(context),
|
|
new DispatchDataService(context),
|
|
new WorkOrderDetailDataService(context),
|
|
new WorkOrderAccountResolver(new AccountDataService(context), new LocationDataService(context)),
|
|
new UserDataService(context),
|
|
new TeamPermissionOverrideDataService(context),
|
|
new TeamPermissionPolicy(),
|
|
TimeProvider.System,
|
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
|
new VendorDocumentDataService(context));
|
|
|
|
private static async Task<IActionResult> RevokeAsync(
|
|
ApplicationDbContext context,
|
|
RevokeRoute route,
|
|
ClaimsPrincipal user,
|
|
int upliftId,
|
|
string? reason)
|
|
{
|
|
var workOrderFlow = NewWorkOrderUpliftService(context);
|
|
var httpContext = new DefaultHttpContext { User = user };
|
|
|
|
if (route == RevokeRoute.WorkOrder)
|
|
{
|
|
var controller = new WorkOrderDetailController(
|
|
Mock.Of<IWorkOrderDetailService>(),
|
|
Mock.Of<IWorkOrderCommentService>(),
|
|
workOrderFlow,
|
|
Mock.Of<IWorkOrderUpliftEvidenceService>(),
|
|
Mock.Of<ILogger<WorkOrderDetailController>>())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
|
};
|
|
return await controller.RevokeUplift(
|
|
WorkOrderId,
|
|
upliftId,
|
|
new RevokeWorkOrderUpliftRequestDto { Reason = reason },
|
|
CancellationToken.None);
|
|
}
|
|
|
|
var upliftService = new UpliftService(
|
|
new UpliftDataService(context),
|
|
new DispatchDataService(context),
|
|
Mock.Of<IVendorDocumentStoragePort>(),
|
|
TimeProvider.System,
|
|
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
|
|
workOrderFlow);
|
|
var approvals = new UpliftController(upliftService, Mock.Of<ILogger<UpliftController>>())
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = httpContext },
|
|
};
|
|
return await approvals.Revoke(
|
|
upliftId,
|
|
new UpliftController.DecisionRequest { Note = reason },
|
|
CancellationToken.None);
|
|
}
|
|
|
|
private static async Task AssertUnchangedAsync(ApplicationDbContext context, int upliftId, string status)
|
|
{
|
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == upliftId);
|
|
stored.Status.Should().Be(status);
|
|
stored.DecisionNote.Should().BeNull();
|
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(2200m);
|
|
(await context.WorkOrderAuditLogs.AsNoTracking().AnyAsync(a => a.Action == "uplift_revoke")).Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WorkOrderRoute_AdminRevokingAutoApprovedUpliftTheyRequested_IsForbiddenAndChangesNothing()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedAsync(context);
|
|
|
|
var result = await RevokeAsync(
|
|
context,
|
|
RevokeRoute.WorkOrder,
|
|
OrgWideUser("admin-1", "Admin"),
|
|
AutoApprovedId,
|
|
"Wrong quote attached");
|
|
|
|
var refused = result.Should().BeOfType<ObjectResult>().Subject;
|
|
refused.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
|
refused.Value.Should().BeOfType<Response>().Which.Message
|
|
.Should().StartWith("You are not authorized to perform this action");
|
|
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApprovalsRoute_AdminRevokingAutoApprovedUplift_IsRefusedAndChangesNothing()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedAsync(context);
|
|
|
|
var result = await RevokeAsync(
|
|
context,
|
|
RevokeRoute.UpliftApprovals,
|
|
OrgWideUser("admin-1", "Admin"),
|
|
AutoApprovedId,
|
|
"Wrong quote attached");
|
|
|
|
var refused = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
refused.Value.Should().BeOfType<Response>().Which.Message
|
|
.Should().StartWith("This uplift request cannot be revoked");
|
|
await AssertUnchangedAsync(context, AutoApprovedId, UpliftStatus.NoApprovalRequired);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(RevokeRoute.UpliftApprovals)]
|
|
[InlineData(RevokeRoute.WorkOrder)]
|
|
public async Task AdminRevokingAdminApprovedUpliftWithReason_Succeeds(RevokeRoute route)
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedAsync(context);
|
|
|
|
var result = await RevokeAsync(
|
|
context,
|
|
route,
|
|
OrgWideUser("admin-1", "Admin"),
|
|
AdminApprovedId,
|
|
" Approved against the wrong quote ");
|
|
|
|
result.Should().BeOfType<OkObjectResult>();
|
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AdminApprovedId);
|
|
stored.Status.Should().Be(UpliftStatus.Revoked);
|
|
stored.DecisionNote.Should().Be("Approved against the wrong quote");
|
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1400m);
|
|
var audit = await context.WorkOrderAuditLogs.AsNoTracking().SingleAsync(a => a.Action == "uplift_revoke");
|
|
audit.OldValue.Should().Be(UpliftStatus.Approved);
|
|
audit.NewValue.Should().Be(UpliftStatus.Revoked);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(RevokeRoute.UpliftApprovals)]
|
|
[InlineData(RevokeRoute.WorkOrder)]
|
|
public async Task AdminRevokingAdminApprovedUpliftWithoutReason_IsRefused(RevokeRoute route)
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedAsync(context);
|
|
|
|
var result = await RevokeAsync(context, route, OrgWideUser("admin-1", "Admin"), AdminApprovedId, " ");
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>();
|
|
await AssertUnchangedAsync(context, AdminApprovedId, UpliftStatus.Approved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WorkOrderRoute_DispatcherRevokingOwnAutoApprovedUpliftWithoutReason_Succeeds()
|
|
{
|
|
await using var context = CreateContext();
|
|
await SeedAsync(context);
|
|
var own = await context.DispatchUpliftRequests.SingleAsync(u => u.Id == AutoApprovedId);
|
|
own.createdby = "dispatcher-1";
|
|
await context.SaveChangesAsync();
|
|
|
|
var result = await RevokeAsync(
|
|
context,
|
|
RevokeRoute.WorkOrder,
|
|
OrgWideUser("dispatcher-1", "Dispatcher"),
|
|
AutoApprovedId,
|
|
null);
|
|
|
|
result.Should().BeOfType<OkObjectResult>();
|
|
var stored = await context.DispatchUpliftRequests.AsNoTracking().SingleAsync(u => u.Id == AutoApprovedId);
|
|
stored.Status.Should().Be(UpliftStatus.Revoked);
|
|
stored.DecisionNote.Should().BeNull();
|
|
(await context.Dispatches.AsNoTracking().SingleAsync(d => d.Id == DispatchId)).NTEAmount.Should().Be(1000m);
|
|
}
|
|
}
|