mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-05 03:42:12 +00:00
SH-44 and SH-82 both left "blocks, or requires explicit confirmation" to be decided with the team, and the implementation took the blocking branch. SH-254 settles it the other way: the approved design offers "Deactivate anyway" beside the list of open work orders. Deactivation with open work orders is now permitted, but only when the caller says it has shown them: ConfirmOpenWorkOrders on the update DTO and a confirmOpenWorkOrders query parameter on the delete route. Absent the flag the existing guard still throws, so nothing deactivates by accident and no caller loses the check by omission. confirmOpenWorkOrders is a required parameter on DeleteVendorAsync rather than an optional one, so every call site states its intent.
771 lines
29 KiB
C#
771 lines
29 KiB
C#
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());
|
|
|
|
[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 GetVendorDirectoryPaged_ForwardsCancellationToken()
|
|
{
|
|
var data = new Mock<IVendorDataService>();
|
|
using var cancellation = new CancellationTokenSource();
|
|
var token = cancellation.Token;
|
|
data.Setup(x => x.GetDirectoryPagedAsync(
|
|
1,
|
|
12,
|
|
"clayton",
|
|
true,
|
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
|
It.IsAny<IReadOnlyCollection<string>?>(),
|
|
token))
|
|
.ReturnsAsync((Array.Empty<VendorDirectoryRow>(), 0));
|
|
|
|
await NewService(data).GetVendorDirectoryPagedAsync(
|
|
1,
|
|
12,
|
|
"clayton",
|
|
true,
|
|
cancellationToken: token);
|
|
|
|
data.Verify(x => x.GetDirectoryPagedAsync(
|
|
1,
|
|
12,
|
|
"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>());
|
|
|
|
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_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>());
|
|
|
|
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());
|
|
|
|
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());
|
|
|
|
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());
|
|
}
|
|
}
|