using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => new( data.Object, Mock.Of(), Mock.Of(), Microsoft.Extensions.Options.Options.Create(new FrontendOptions()), new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() { var data = new Mock(); Vendor? saved = null; data.Setup(x => x.AddAsync(It.IsAny())) .ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; }); var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO { Name = "Gateway Plumbing", ContactName = "Jordan Lee", Email = "dispatch@gateway.example", Phone = "(555) 555-0100", CompanyPhone = "(555) 555-0199", Address = "10 Lake St", City = "Chicago", State = "IL", Zipcode = "60601", TradeSpecialties = "Plumbing, Backflow", GoogleMapsUrl = "https://maps.google.com/example", Notes = "After-hours dispatch available", IsActive = true }, "42"); saved.Should().NotBeNull(); saved!.ContactName.Should().Be("Jordan Lee"); saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); saved.CompanyPhone.Should().Be("(555) 555-0199"); saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); saved.Notes.Should().Be("After-hours dispatch available"); saved.City.Should().Be("Chicago"); result.IsActive.Should().BeTrue(); result.ContactName.Should().Be("Jordan Lee"); } [Fact] public async Task UpdateVendor_UpdatesProfileAndActiveState() { var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO { Name = "New", ContactName = "Casey", TradeSpecialties = "HVAC", IsActive = false }, "42"); existing.CompanyName.Should().Be("New"); existing.ContactName.Should().Be("Casey"); existing.TradeSpecialties.Should().Be("HVAC"); existing.IsActive.Should().BeFalse(); existing.LastModifierUserId.Should().Be(42); result.IsActive.Should().BeFalse(); } [Fact] public async Task DeleteVendor_DeactivatesWithoutDeletingHistory() { var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); existing.IsActive.Should().BeFalse(); existing.IsDeleted.Should().NotBeTrue(); existing.DeletionTime.Should().BeNull(); existing.LastModificationTime.Should().NotBeNull(); } [Fact] public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields() { var data = new Mock(); data.Setup(x => x.GetPagedAsync( 1, 12, "chicago", false, It.IsAny?>(), It.IsAny?>(), It.IsAny?>(), It.IsAny?>())) .ReturnsAsync((new[] { new Vendor { Id = 10, CompanyName = "Inactive Vendor", ContactName = "Taylor", City = "Chicago", State = "IL", TradeSpecialties = "Electrical", IsActive = false } }.AsEnumerable(), 1)); var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false); result.TotalCount.Should().Be(1); result.Items.Single().Should().BeEquivalentTo(new { Id = 10, Name = "Inactive Vendor", ContactName = "Taylor", City = "Chicago", State = "IL", TradeSpecialties = "Electrical", IsActive = false }, options => options.ExcludingMissingMembers()); } [Fact] public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() { var vendor = new Vendor { Id = 11, CompanyName = "History Vendor", Dispatches = new List { new() { WorkOrderId = 100, DispatchWorkOrders = new List { new() { WorkOrderId = 100 }, new() { WorkOrderId = 101 } } }, new() { WorkOrderId = 101 } } }; var data = new Mock(); data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor); var result = await NewService(data).GetVendorByIdAsync(11); result.Should().NotBeNull(); result!.TotalJobs.Should().Be(2); } [Fact] public async Task GetVendorsPaged_ForwardsFacetFilters() { var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" }; var trades = new[] { "Backflow Preventers" }; var locations = new[] { "Dallas, TX" }; var jobBuckets = new[] { "100-149" }; var data = new Mock(); data.Setup(x => x.GetPagedAsync( 1, 25, "gateway", true, companies, trades, locations, jobBuckets)) .ReturnsAsync((Enumerable.Empty(), 0)); await NewService(data).GetVendorsPagedAsync( 1, 25, "gateway", true, companies, trades, locations, jobBuckets); data.VerifyAll(); } [Fact] public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl() { var validator = new CreateVendorValidation(); var result = validator.Validate(new CreateVendorDTO { Name = "Gateway Plumbing", ContactName = "", GoogleMapsUrl = "http://maps.google.com/gateway" }); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } [Fact] public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive() { var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); data.Setup(x => x.GetLinkedWorkOrdersAsync(20)) .ReturnsAsync(new List { new() { WorkOrderId = 500, WorkOrderNumber = "WO-500", Status = "Scheduled", LifecycleStatus = LifecycleStatus.Scheduled } }); var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42"); await act.Should().ThrowAsync(); existing.IsActive.Should().BeTrue(); data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); } [Fact] public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName() { var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing); data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null); var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO { CompanyId = 999, Name = "Fallback Try" }, "42"); await act.Should().ThrowAsync(); data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny()), Times.Never); data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); } [Fact] public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany() { var data = new Mock(); var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" }; data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); Vendor? saved = null; data.Setup(x => x.AddAsync(It.IsAny())) .ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; }); var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO { Name = "Acme", CompanyId = 77, ContactName = "Riley" }, "42"); saved.Should().NotBeNull(); saved!.CompanyId.Should().Be(77); result.CompanyId.Should().Be(77); data.Verify(x => x.AddCompanyAsync(It.IsAny()), Times.Never); } [Fact] public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact() { var validator = new CreateVendorValidation(); var result = validator.Validate(new CreateVendorDTO { Name = "Acme", ContactName = "Riley", Phone = "555-1234", PreferredContact = "Fax" }); result.IsValid.Should().BeFalse(); result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone)); result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); } [Fact] public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation() { var data = new Mock(); Vendor? saved = null; data.Setup(x => x.AddAsync(It.IsAny())) .ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 41; return vendor; }); await NewService(data).CreateVendorAsync(new CreateVendorDTO { Name = "Acme", ContactName = "Riley", Phone = "+1 312 555 0100" }, "42"); saved!.Phone.Should().Be("(312) 555-0100"); } [Fact] public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany() { var existing = new Vendor { Id = 42, CompanyId = 77, CompanyName = "Acme", Address = "Old address", IsActive = true }; var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme", Address = "Old address" }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing); data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO { Name = "Acme Services", Address = "200 New Street" }, "42"); company.Name.Should().Be("Acme Services"); company.NormalizedName.Should().Be("acme services"); company.Address.Should().Be("200 New Street"); existing.Address.Should().Be("200 New Street"); data.Verify(x => x.UpdateCompanyAsync(company), Times.Once); } [Fact] public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields() { var existing = new Vendor { Id = 43, CompanyId = 77, CompanyName = "Acme", Address = "Keep this address", CompanyPhone = "(312) 555-0199", IsActive = true }; var sparseCompany = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing); data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany); data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO { Name = "Acme" }, "42"); existing.Address.Should().Be("Keep this address"); existing.CompanyPhone.Should().Be("(312) 555-0199"); } [Fact] public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() { var validator = new WorkOrderVendorUpdateValidation(); var result = validator.Validate(new WorkOrderVendorUpdateDTO { WorkOrderId = 5, Phone = "(312) 555-0100", PreferredContact = "Text" }); result.IsValid.Should().BeTrue(); } [Fact] public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder() { var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false); var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO { WorkOrderId = 777, ContactName = "Should Not Apply" }, "42"); await act.Should().ThrowAsync(); existing.ContactName.Should().Be("Original"); data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); } [Fact] public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues() { var existing = new Vendor { Id = 50, CompanyName = "Assigned", ContactName = "Old Name", Phone = "(312) 555-0000", PreferredContact = "Phone", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true); var auditLogs = new List(); data.Setup(x => x.UpdateWithAuditLogsAsync( existing, It.IsAny>())) .Callback>((_, logs) => auditLogs.AddRange(logs)) .Returns(Task.CompletedTask); await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO { WorkOrderId = 777, ContactName = "New Name", Phone = "(312) 555-0199", PreferredContact = "Email" }, "42"); existing.ContactName.Should().Be("New Name"); existing.Phone.Should().Be("(312) 555-0199"); existing.PreferredContact.Should().Be("Email"); auditLogs.Should().NotBeEmpty(); auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name"); auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199"); auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email"); } [Fact] public async Task GetDeactivationImpact_RejectsUnknownVendor() { var data = new Mock(); data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false); var act = () => NewService(data).GetDeactivationImpactAsync(404); await act.Should().ThrowAsync() .WithMessage("*404*not found"); data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } }