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() .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? userData = null) { userData ??= new Mock(); var vendorData = new VendorDataService(context); var tokenService = new VendorPortalTokenService( vendorData, Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions())); var upliftData = new Mock(); upliftData.Setup(u => u.GetForVendorDispatchAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); return new VendorPortalService( tokenService, new DispatchDataService(context), upliftData.Object, new CommentDataService(context), userData.Object, Mock.Of(), new VendorDocumentDataService(context), Mock.Of(), 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(); 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()) .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(); 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(); } [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(); userData.Setup(u => u.GetProfileAsync("dispatcher-1", It.IsAny())) .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(); } [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()) .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().WithMessage("*locked*"); context.VendorCompletionDocuments.Should().BeEmpty(); } }