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