mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-03 01:03:30 +00:00
* refactor(api): enforce service and data-service boundaries * refactor(api): complete feature service boundaries * refactor(identity): enforce service and data boundaries * refactor(vendors): enforce service and data boundaries * refactor(workorders): enforce service and data boundaries * refactor(backend): enforce architecture and optimize dispatch * style(backend): format changed architecture files * fix(architecture): address backend review follow-ups * fix(backend): sanitize exception disclosure in changed API endpoints Replace raw exception-message disclosure (ex.Message) returned to API callers with a stable sanitized public message plus correlated structured internal logging, across the endpoints changed in this PR. - Add SanitizedErrors helper: logs the original exception at Error with a generated correlation id and returns a stable public message referencing it so support can trace without exposing internals. - Inject ILogger<T> into the 14 changed controllers and route every ex.Message/dbex.Message disclosure through the helper, preserving status codes, response shapes, and business data (e.g. OpenWorkOrders). - Leave FluentValidation (vex.Errors) and existing fixed-message catches untouched; out-of-scope controllers (Account/Contact/Employee/Asset/ PMSchedule) are unchanged. - Add focused tests proving internal exception text is not returned and that Error logging carrying the original exception is invoked. * fix(architecture): abstract job run state access * style: format board update service * test: use collection assertion idiom
491 lines
18 KiB
C#
491 lines
18 KiB
C#
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using FluentAssertions;
|
|
using FluentValidation;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Implementation;
|
|
using SeaHaven.Services.Interfaces;
|
|
using SeaHaven.Services.Validation;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class VendorServiceTests
|
|
{
|
|
private static VendorService NewService(Mock<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");
|
|
|
|
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 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_RequiresTechnicianNameAndHttpsMapUrl()
|
|
{
|
|
var validator = new CreateVendorValidation();
|
|
|
|
var result = validator.Validate(new CreateVendorDTO
|
|
{
|
|
Name = "Gateway Plumbing",
|
|
ContactName = "",
|
|
GoogleMapsUrl = "http://maps.google.com/gateway"
|
|
});
|
|
|
|
result.IsValid.Should().BeFalse();
|
|
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
|
|
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
|
|
{
|
|
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
|
|
var data = new Mock<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"
|
|
}, "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"
|
|
}, "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);
|
|
}
|
|
}
|