shoc-backend/Api.SeaHavenIndustries.Tests/VendorPortalRefuseTests.cs
2026-08-11 08:58:19 -03:00

312 lines
12 KiB
C#

using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalRefuseTests
{
private const string Token = "vendor-refuse-test-token";
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task<(Vendor Vendor, WorkOrder WorkOrder, Dispatch Dispatch)> SeedSentDispatch(
ApplicationDbContext context, string status = "Sent")
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
WorkerOrderTitle = "Repair",
SiteCode = "SITE-42",
Service = "Emergency plumbing"
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = status,
DispatchNumber = "DSP-1"
};
context.Dispatches.Add(dispatch);
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = vendor.Id,
Token = Token,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
return (vendor, workOrder, dispatch);
}
private static VendorPortalService NewService(
ApplicationDbContext context,
Mock<IUserDataService>? userData = null)
{
userData ??= new Mock<IUserDataService>();
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var upliftData = new Mock<IUpliftDataService>();
upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<PortalUpliftData>());
return new VendorPortalService(
tokenService,
new DispatchDataService(context),
upliftData.Object,
new CommentDataService(context),
userData.Object,
Mock.Of<IEmailSender>(),
new VendorDocumentDataService(context),
Mock.Of<IVendorDocumentStoragePort>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()),
TimeProvider.System);
}
private static VendorPortalSession SessionFor(Vendor vendor) => new()
{
Id = vendor.Id,
CompanyName = vendor.CompanyName
};
[Fact]
public async Task RefuseDispatch_NoReason_RefusesStagesAuditAndOmitsComment()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
result.Status.Should().Be("Refused");
result.Id.Should().Be(dispatch.Id);
result.RefusedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Refused");
reloaded.LastModificationTime.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
var audit = await context.WorkOrderAuditLogs.SingleAsync();
audit.Action.Should().Be("vendor_refuse");
audit.OldValue.Should().Be("Sent");
audit.NewValue.Should().Be("Refused");
context.Comments.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_NonblankReason_TrimsAndStagesRefusalComment()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var result = await service.RefuseDispatchAsync(
SessionFor(vendor), dispatch.Id, " Out of capacity ", CancellationToken.None);
result.Status.Should().Be("Refused");
var comment = await context.Comments.SingleAsync();
comment.RecordType.Should().Be("refusal");
comment.CommentType.Should().Be("vendor");
comment.Commenttext.Should().Be("Out of capacity");
comment.Commenter.Should().Be(vendor.CompanyName);
comment.CreatedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task RefuseDispatch_FiveHundredCharReason_Accepted()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var reason = new string('x', 500);
var result = await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None);
result.Status.Should().Be("Refused");
var comment = await context.Comments.SingleAsync();
comment.Commenttext.Should().HaveLength(500);
}
[Fact]
public async Task RefuseDispatch_OverFiveHundredChars_Rejected()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var reason = new string('x', 501);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, reason, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Sent");
context.WorkOrderAuditLogs.Should().BeEmpty();
context.Comments.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_NonSentStatus_Rejected()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context, status: "Acknowledged");
var service = NewService(context);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*Acknowledged*");
context.WorkOrderAuditLogs.Should().BeEmpty();
}
[Fact]
public async Task RefuseDispatch_WrongVendor_ReturnsKeyNotFoundViaScopedLookup()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
var otherSession = new VendorPortalSession { Id = vendor.Id + 999, CompanyName = "Other" };
var act = () => service.RefuseDispatchAsync(otherSession, dispatch.Id, null, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
var reloaded = await context.Dispatches.FindAsync(dispatch.Id);
reloaded!.Status.Should().Be("Sent");
}
[Fact]
public async Task RefuseDispatch_ForwardsCancellationToken()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
using var cts = new CancellationTokenSource();
cts.Cancel();
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task GetDispatchDetail_IncludesSiteCode_StatusUpdatedAt_AndAllowlistedDispatcherContact()
{
using var context = NewContext();
var (vendor, workOrder, dispatch) = await SeedSentDispatch(context);
var statusUpdatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc);
dispatch.LastModificationTime = statusUpdatedAt;
await context.SaveChangesAsync();
context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = workOrder.Id,
Action = "dispatch",
UserId = "dispatcher-1",
CreatedAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetProfileAsync("dispatcher-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new UserProfileData
{
Id = "dispatcher-1",
FirstName = "Dana",
Email = "dana@shoc.test",
Contact = "555-0199"
});
var service = NewService(context, userData);
var detail = await service.GetDispatchDetailAsync(SessionFor(vendor), dispatch.Id, CancellationToken.None);
detail.Should().NotBeNull();
detail!.WorkOrder!.SiteCode.Should().Be("SITE-42");
detail.WorkOrder.Service.Should().Be("Emergency plumbing");
detail.StatusUpdatedAt.Should().Be(statusUpdatedAt);
detail.DispatcherContact.Should().NotBeNull();
detail.DispatcherContact!.Name.Should().Be("Dana");
detail.DispatcherContact.Email.Should().Be("dana@shoc.test");
detail.DispatcherContact.Phone.Should().Be("555-0199");
var contactJson = System.Text.Json.JsonSerializer.Serialize(detail.DispatcherContact);
contactJson.Should().NotContain("dispatcher-1");
contactJson.Should().NotContain("Id");
}
[Fact]
public async Task RefuseDispatch_ThenRefuseAgain_IsRejectedAsNonSent()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var act = () => service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task RefuseDispatch_MarksRefusedSoPortalLocksApply()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var act = () => service.RequestCancelAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*Refused*");
}
[Fact]
public async Task UploadCompletionDocument_RefusedDispatch_IsLocked()
{
using var context = NewContext();
var (vendor, _, dispatch) = await SeedSentDispatch(context);
var service = NewService(context);
await service.RefuseDispatchAsync(SessionFor(vendor), dispatch.Id, null, CancellationToken.None);
var bytes = "%PDF-1.4\ncompletion"u8.ToArray();
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "completion.pdf")
{
Headers = new HeaderDictionary(),
ContentType = "application/pdf"
};
var act = () => service.UploadCompletionDocumentAsync(
SessionFor(vendor), dispatch.Id, file, null, null, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*locked*");
context.VendorCompletionDocuments.Should().BeEmpty();
}
}