mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 08:23:12 +00:00
312 lines
12 KiB
C#
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();
|
|
}
|
|
}
|