shoc-backend/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs
Alexandre Brandizzi 8515676f4d feat(vendors): add admin-assigned vendor company Area
Seed an organization-wide Area catalogue (East, Central, West, California)
with stable ids, add a nullable AreaId to VendorCompany, allow only Admins
to change it through the roster endpoints, expose areas facet metadata and
an areas[n] company-directory filter with the __unassigned__ sentinel.
2026-09-16 11:34:45 -03:00

1004 lines
39 KiB
C#

using Microsoft.Extensions.Logging;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
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<IVendorDataService> data) =>
new(
data.Object,
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation(),
Mock.Of<ILogger<VendorService>>());
[Fact]
public async Task CreateVendor_PersistsCompleteProfile()
{
var data = new Mock<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).DeleteVendorAsync(9, "42", confirmOpenWorkOrders: false);
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<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
12,
"chicago",
false,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>()))
.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 GetVendorCompanyDirectoryPaged_ForwardsCancellationTokenAndMapsNestedTechnicians()
{
var data = new Mock<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
1,
12,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token))
.ReturnsAsync((new[]
{
new VendorCompanyGroup
{
Id = 7,
CompanyId = 3,
CompanyName = "Gateway Plumbing",
ContactName = "Primary Tech",
Email = "company@gatewayplumbing.example",
TotalJobs = 4,
Technicians = new[]
{
new VendorCompanyGroupTechnician
{
Id = 7,
ContactName = "Primary Tech",
Email = "primary@example.com",
TotalJobs = 4,
IsActive = true
}
}
}
}.AsEnumerable().ToList(), 1));
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1,
12,
"clayton",
true,
cancellationToken: token);
var group = result.Items.Single();
group.Email.Should().Be("company@gatewayplumbing.example");
group.Technicians.Should().ContainSingle()
.Which.Email.Should().Be("primary@example.com");
data.Verify(x => x.GetCompanyDirectoryPagedAsync(
1,
12,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
token), Times.Once);
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BoundsPageInputsBeforeBuildingTheGroupKeyPredicate()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
1,
100,
null,
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(0, 500);
result.Page.Should().Be(1);
result.PageSize.Should().Be(100);
data.VerifyAll();
}
[Fact]
public async Task GetVendorTechnicianDirectoryPaged_ForwardsRawPaginationAndCancellationToken()
{
var data = new Mock<IVendorDataService>();
using var cancellation = new CancellationTokenSource();
var token = cancellation.Token;
data.Setup(x => x.GetDirectoryPagedAsync(
2,
500,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token))
.ReturnsAsync((new[]
{
new VendorDirectoryRow { VendorId = 7, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech A" },
new VendorDirectoryRow { VendorId = 9, CompanyId = 3, CompanyName = "Gateway Plumbing", ContactName = "Tech B" }
}.AsEnumerable().ToList(), 2));
var result = await NewService(data).GetVendorTechnicianDirectoryPagedAsync(
2,
500,
"clayton",
true,
cancellationToken: token);
// Legacy pagination semantics: page and pageSize (including 500) pass through
// unclamped, and same-company technicians stay as distinct rows.
result.Page.Should().Be(2);
result.PageSize.Should().Be(500);
result.Items.Should().HaveCount(2);
result.Items.Select(v => v.Id).Should().BeEquivalentTo(new[] { 7, 9 });
result.Items.Should().OnlyContain(v => v.Technicians.Count == 0);
data.Verify(x => x.GetDirectoryPagedAsync(
2,
500,
"clayton",
true,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
token), Times.Once);
}
[Fact]
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
{
var vendor = new Vendor
{
Id = 11,
CompanyName = "History Vendor",
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
var data = new Mock<IVendorDataService>();
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<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets))
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
await NewService(data).GetVendorsPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets);
data.VerifyAll();
}
[Fact]
public void CreateVendor_AcceptsOptionalTechnicianButRequiresContactGroupAndHttpsUrl()
{
var validator = new CreateVendorValidation();
// SH-199: technician (ContactName) is optional; zero-technician create is valid
// as long as a company phone or email is present and the map URL is HTTPS.
var valid = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
Email = "dispatch@gateway.example",
GoogleMapsUrl = "https://maps.google.com/gateway"
});
valid.IsValid.Should().BeTrue();
valid.Errors.Should().NotContain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
// Missing both company phone and email fails the contact-group rule.
var noContact = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
GoogleMapsUrl = "https://maps.google.com/gateway"
});
noContact.IsValid.Should().BeFalse();
noContact.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.CompanyPhone));
// Non-HTTPS map URL still rejected.
var badUrl = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
Email = "dispatch@gateway.example",
GoogleMapsUrl = "http://maps.google.com/gateway"
});
badUrl.IsValid.Should().BeFalse();
badUrl.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
}
[Fact]
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_WhenConfirmed_Deactivates()
{
var existing = new Vendor { Id = 21, CompanyName = "Confirmed", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(21)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(21))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 501,
WorkOrderNumber = "WO-501",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).UpdateVendorAsync(
21,
new UpdateVendorDTO { IsActive = false, ConfirmOpenWorkOrders = true },
"42");
existing.IsActive.Should().BeFalse();
data.Verify(x => x.UpdateAsync(existing), Times.Once);
}
[Fact]
public async Task DeleteVendor_WithOpenWorkOrders_WhenNotConfirmed_ThrowsAndLeavesActive()
{
var existing = new Vendor { Id = 22, CompanyName = "Guarded Delete", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(22)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(22))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 502,
WorkOrderNumber = "WO-502",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
var act = () => NewService(data).DeleteVendorAsync(22, "42", confirmOpenWorkOrders: false);
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
existing.IsActive.Should().BeTrue();
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task DeleteVendor_WithOpenWorkOrders_WhenConfirmed_Deactivates()
{
var existing = new Vendor { Id = 23, CompanyName = "Confirmed Delete", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(23)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(23))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 503,
WorkOrderNumber = "WO-503",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).DeleteVendorAsync(23, "42", confirmOpenWorkOrders: true);
existing.IsActive.Should().BeFalse();
data.Verify(x => x.UpdateAsync(existing), Times.Once);
}
[Fact]
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
{
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
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<VendorDeactivationBlockedException>();
existing.IsActive.Should().BeTrue();
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
{
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
var data = new Mock<IVendorDataService>();
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<ValidationException>();
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
{
var data = new Mock<IVendorDataService>();
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<Vendor>()))
.ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; });
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Acme",
CompanyId = 77,
ContactName = "Riley",
Email = "acme@example.com"
}, "42");
saved.Should().NotBeNull();
saved!.CompanyId.Should().Be(77);
result.CompanyId.Should().Be(77);
data.Verify(x => x.AddCompanyAsync(It.IsAny<VendorCompany>()), 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<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.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",
Email = "acme@example.com"
}, "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<IVendorDataService>();
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<IVendorDataService>();
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<IVendorDataService>();
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<InvalidOperationException>();
existing.ContactName.Should().Be("Original");
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), 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<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
var auditLogs = new List<VendorAuditLog>();
data.Setup(x => x.UpdateWithAuditLogsAsync(
existing,
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, 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<IVendorDataService>();
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
var act = () => NewService(data).GetDeactivationImpactAsync(404);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*404*not found");
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_CompaniesAndLocationsUseCompanyOwnedFieldsOverTechnicianFields()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
new()
{
Id = 1,
Name = "Owner Co",
CompanyPhone = "(555) 000-0001",
Email = "owner@example.com",
Address = "Owner Addr",
City = "OwnerCity",
State = "OS",
Zip = "00001",
GoogleMapsUrl = "https://owner.example/map"
}
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
var company = facets.Companies.Single();
company.Name.Should().Be("Owner Co");
company.CompanyId.Should().Be(1);
company.CompanyPhone.Should().Be("(555) 000-0001");
company.Email.Should().Be("owner@example.com");
company.Address.Should().Be("Owner Addr");
company.City.Should().Be("OwnerCity");
company.State.Should().Be("OS");
company.Zip.Should().Be("00001");
company.GoogleMapsUrl.Should().Be("https://owner.example/map");
company.PreferredContact.Should().BeNull();
var location = facets.Locations.Single();
location.City.Should().Be("OwnerCity");
location.State.Should().Be("OS");
location.Label.Should().Be("OwnerCity, OS");
// SH-249: technician free text is no longer a trades source; the canonical
// vocabulary comes from the Trade reference table instead.
facets.Trades.Should().BeEmpty();
}
[Fact]
public async Task GetFacetsAsync_LocationsIncludeAddressOnlyCompaniesWithAddressLabel()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
new() { Id = 1, Name = "Address Only Co", Address = " 789 Warehouse Row " },
new() { Id = 2, Name = "City State Co", Address = "12 Ignored Way", City = "Denver", State = "CO" },
new() { Id = 3, Name = "Nowhere Co" }
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
facets.Locations.Select(l => l.Label).Should().BeEquivalentTo(
new[] { "789 Warehouse Row", "Denver, CO" });
var addressOnly = facets.Locations.Single(l => l.Label == "789 Warehouse Row");
addressOnly.City.Should().BeNull();
addressOnly.State.Should().BeNull();
var cityState = facets.Locations.Single(l => l.Label == "Denver, CO");
cityState.City.Should().Be("Denver");
cityState.State.Should().Be("CO");
}
[Fact]
public async Task GetFacetsAsync_CompaniesAreUniqueAndOrderedCaseInsensitively()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>
{
new() { Id = 1, Name = "Beta Co" },
new() { Id = 2, Name = "alpha co" },
new() { Id = 3, Name = "Alpha Co" }
});
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
// Case-insensitive dedup collapses "alpha co"/"Alpha Co" to one entry, and
// ordering places "alpha co" before "Beta Co" (case-insensitive, not Ordinal).
facets.Companies.Select(c => c.Name).Should().BeEquivalentTo(
new[] { "alpha co", "Beta Co" },
opts => opts.WithStrictOrdering());
}
[Fact]
public async Task GetFacetsAsync_TradesComeFromCanonicalReferenceDataNotTechnicianFreeText()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
data.Setup(x => x.GetActiveTradesAsync(cts.Token))
.ReturnsAsync(TradeCatalog.CanonicalTrades
.Select((name, index) => new Trade
{
Id = index + 1,
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
SortOrder = index + 1
})
.ToList());
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>());
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
// SH-249: facets serve the canonical Trade vocabulary in catalog order,
// regardless of technician TradeSpecialties free text.
facets.Trades.Should().BeEquivalentTo(
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
// G7 evidence: the caller's cancellation token reaches the data service.
data.Verify(x => x.GetActiveTradesAsync(cts.Token), Times.Once);
}
[Fact]
public async Task GetFacetsAsync_ReturnsCanonicalTradesOnEmptyVendorDatabase()
{
await using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
// Empty vendor/company data; only the canonical Trade reference rows exist
// (in production these are seeded by the SH249_TradesVocabulary migration).
context.Trades.AddRange(TradeCatalog.CanonicalTrades.Select((name, index) => new Trade
{
Id = index + 1,
Name = name,
NormalizedName = TradeCatalog.Normalize(name),
SortOrder = index + 1
}));
await context.SaveChangesAsync();
var service = new VendorService(
new VendorDataService(context),
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation(),
Mock.Of<ILogger<VendorService>>());
var facets = await service.GetFacetsAsync(null, CancellationToken.None);
facets.Companies.Should().BeEmpty();
facets.Locations.Should().BeEmpty();
facets.Trades.Should().BeEquivalentTo(
TradeCatalog.CanonicalTrades,
opts => opts.WithStrictOrdering());
}
private static Mock<IVendorDataService> DirectoryDataWithAreas(Action<VendorAreaFilter?> capture)
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Area>
{
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 3, Name = "West", NormalizedName = "west" }
});
data.Setup(x => x.GetCompanyDirectoryPagedAsync(
It.IsAny<int>(),
It.IsAny<int>(),
It.IsAny<string?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<VendorAreaFilter?>(),
It.IsAny<CancellationToken>()))
.Callback<int, int, string?, bool?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, IReadOnlyCollection<string>?, VendorAreaFilter?, CancellationToken>(
(_, _, _, _, _, _, _, _, filter, _) => capture(filter))
.ReturnsAsync((new List<VendorCompanyGroup>(), 0));
return data;
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_ResolvesAreaIdsAndUnassignedSentinel()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { " 1 ", "__unassigned__", "", "1" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().Equal(1);
captured.IncludeUnassigned.Should().BeTrue();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_UnknownArchivedOrLabelAreaValuesMatchNobody()
{
VendorAreaFilter? captured = null;
var data = DirectoryDataWithAreas(filter => captured = filter);
// 4 is not in the active catalogue (unknown or archived); "West" is a label.
var result = await NewService(data).GetVendorCompanyDirectoryPagedAsync(
1, 10, areas: new[] { "4", "West", "-3" });
captured.Should().NotBeNull();
captured!.AreaIds.Should().BeEmpty();
captured.IncludeUnassigned.Should().BeFalse();
result.Items.Should().BeEmpty();
}
[Fact]
public async Task GetVendorCompanyDirectoryPaged_BlankAreaValuesApplyNoAreaFilter()
{
VendorAreaFilter? captured = new();
var data = DirectoryDataWithAreas(filter => captured = filter);
await NewService(data).GetVendorCompanyDirectoryPagedAsync(1, 10, areas: new[] { " ", "" });
captured.Should().BeNull();
data.Verify(x => x.GetActiveAreasAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task GetFacetsAsync_AreasComeFromActiveCatalogueOrderedByName()
{
using var cts = new CancellationTokenSource();
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetCompaniesForFacetsAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VendorCompany>());
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Trade>());
data.Setup(x => x.GetActiveAreasAsync(cts.Token))
.ReturnsAsync(new List<Area>
{
new() { Id = 3, Name = "West", NormalizedName = "west" },
new() { Id = 1, Name = "East", NormalizedName = "east" },
new() { Id = 4, Name = "california", NormalizedName = "california" }
});
var facets = await NewService(data).GetFacetsAsync(null, cts.Token);
facets.Areas.Select(area => (area.Id, area.Name)).Should().Equal(
(4, "california"), (1, "East"), (3, "West"));
data.Verify(x => x.GetActiveAreasAsync(cts.Token), Times.Once);
}
}