shoc-backend/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs
Alexandre Brandizzi 67089c2135
SH-281: group vendor directory by company (#115)
* feat(vendors): group directory by company

* style(vendors): format company directory query

* fix(vendors): preserve technician list contract
2026-09-15 16:02:44 -03:00

1047 lines
41 KiB
C#

using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using System.Text.RegularExpressions;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorDataServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApplicationDbContext NewSqlServerContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True")
.Options;
return new ApplicationDbContext(options);
}
private static Vendor VendorWithJobs(
string company,
string city,
string state,
string trades,
int jobCount,
bool isActive = true)
{
return new Vendor
{
CompanyName = company,
ContactName = $"{company} Technician",
City = city,
State = state,
TradeSpecialties = trades,
IsActive = isActive,
IsDeleted = false,
Dispatches = Enumerable.Range(1, jobCount)
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
.ToList()
};
}
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
{
context.Vendors.AddRange(
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
await context.SaveChangesAsync();
}
[Theory]
[InlineData("under-50", "Under Co")]
[InlineData("50-99", "Fifty Co")]
[InlineData("100-149", "Hundred Co")]
[InlineData("150-plus", "One Fifty Co")]
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
string bucket,
string expectedCompany)
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
jobBuckets: new[] { bucket });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be(expectedCompany);
}
[Fact]
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
companies: new[] { "Under Co", "Hundred Co" },
trades: new[] { "HVAC", "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50", "100-149" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Under Co");
}
[Fact]
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetPagedAsync(1, 1, isActive: true);
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
active.TotalCount.Should().Be(1);
inactive.TotalCount.Should().Be(1);
active.Items.Single().CompanyName.Should().Be("Active Co");
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
}
[Fact]
public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" });
cityOnly.TotalCount.Should().Be(1);
cityOnly.Items.Single().CompanyName.Should().Be("CityOnly");
var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" });
stateOnly.TotalCount.Should().Be(1);
stateOnly.Items.Single().CompanyName.Should().Be("StateOnly");
var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" });
full.TotalCount.Should().Be(1);
full.Items.Single().CompanyName.Should().Be("FullLoc");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" });
hvac.TotalCount.Should().Be(1);
hvac.Items.Single().CompanyName.Should().Be("HvacOnly");
var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" });
hvacControls.TotalCount.Should().Be(1);
hvacControls.Items.Single().CompanyName.Should().Be("HvacControls");
var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" });
plumb.TotalCount.Should().Be(0);
plumb.Items.Should().BeEmpty();
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(1);
plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(2);
plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle");
var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" });
roofing.TotalCount.Should().Be(1);
roofing.Items.Single().CompanyName.Should().Be("Middle");
}
[Fact]
public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders()
{
await using var context = NewContext();
var vendor = new Vendor
{
CompanyName = "History Co",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetPagedAsync(1, 12, isActive: true);
result.Items.Single().TotalJobs.Should().Be(2);
}
[Fact]
public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph()
{
await using var context = NewContext();
var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2);
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var service = new VendorDataService(context);
var result = await service.GetByIdAsync(vendor.Id);
result.Should().NotBeNull();
context.Entry(result!).State.Should().Be(EntityState.Unchanged);
context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse();
result!.Dispatches.Should().BeNull();
}
[Theory]
[InlineData("trade")]
[InlineData("location")]
[InlineData("address-location")]
[InlineData("job-bucket")]
public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var query = filter switch
{
"trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }),
"location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }),
"address-location" => service.BuildDirectoryQuery(locations: new[] { "123 Main St" }),
_ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" })
};
var sql = query.ToQueryString();
sql.Should().Contain("SELECT");
if (filter == "trade")
sql.Should().Contain("TradeSpecialties");
if (filter == "location")
sql.Should().Contain("City");
if (filter == "address-location")
sql.Should().Contain("Address");
if (filter == "job-bucket")
sql.Should().Contain("UNION");
}
[Fact]
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("Dispatches");
sql.Should().Contain("DispatchWorkOrders");
sql.Should().Contain("workOrders");
}
[Fact]
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
{
await using var context = NewContext();
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
context.Vendors.Add(vendor);
context.workOrders.AddRange(
new WorkOrder
{
Id = 701,
WorkerOrderNumber = "WO-701",
WorkerOrderTitle = "Direct And Multi",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 702,
WorkerOrderNumber = "WO-702",
WorkerOrderTitle = "Multi Only",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
});
var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor };
var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor };
context.Dispatches.AddRange(directDispatch, multiDispatch);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 },
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetLinkedWorkOrdersAsync(60);
result.Should().HaveCount(2);
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
}
[Fact]
public async Task GetCompanyDirectoryPagedAsync_GroupsCompaniesAndKeepsLegacyRowsDistinct()
{
await using var context = NewContext();
var linkedCompany = new VendorCompany
{
Name = "Linked Co",
NormalizedName = "linked co",
Email = "dispatch@linked.example",
CompanyPhone = "555-0100",
Address = "100 Company Way",
City = "Chicago",
State = "IL"
};
var companyOnly = new VendorCompany { Name = "Company Only", NormalizedName = "company only" };
context.VendorCompanies.AddRange(linkedCompany, companyOnly);
context.Vendors.AddRange(
new Vendor
{
Company = linkedCompany,
CompanyName = "Stale Linked Name",
ContactName = "Older Tech",
Email = "older@linked.example",
Phone = "555-0101",
TradeSpecialties = "Plumbing",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 101 } }
},
new Vendor
{
Company = linkedCompany,
CompanyName = "Another Stale Name",
ContactName = "Newer Tech",
Email = "newer@linked.example",
Phone = "555-0102",
TradeSpecialties = "HVAC, plumbing",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 102 } }
},
new Vendor { CompanyName = " Legacy Co ", ContactName = "Legacy One", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "legacy co", ContactName = "Legacy Two", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = " ", ContactName = "Unnamed One", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = null, ContactName = "Unnamed Two", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetCompanyDirectoryPagedAsync(1, 50, isActive: true);
totalCount.Should().Be(5);
var linked = items.Single(item => item.CompanyId == linkedCompany.Id);
linked.CompanyName.Should().Be("Linked Co");
linked.Email.Should().Be("dispatch@linked.example");
linked.ContactName.Should().Be("Newer Tech");
linked.Technicians.Should().HaveCount(2);
linked.TotalJobs.Should().Be(2);
linked.TradeSpecialties.Should().Be("HVAC, plumbing");
var canonicalSearch = await service.GetCompanyDirectoryPagedAsync(
1, 50, search: "Linked Co", isActive: true);
canonicalSearch.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
var canonicalCompanyFilter = await service.GetCompanyDirectoryPagedAsync(
1, 50, isActive: true, companies: new[] { "Linked Co" });
canonicalCompanyFilter.Items.Should().ContainSingle(item => item.CompanyId == linkedCompany.Id);
var legacy = items.Single(item =>
item.Technicians.Any(tech => tech.ContactName == "Legacy One"));
legacy.Technicians.Select(tech => tech.ContactName)
.Should().BeEquivalentTo("Legacy One", "Legacy Two");
items.Count(item => item.Technicians.Count == 1 &&
item.Technicians.Single().ContactName?.StartsWith("Unnamed") == true).Should().Be(2);
items.Single(item => item.CompanyId == companyOnly.Id).Technicians.Should().BeEmpty();
}
[Fact]
public async Task GetCompanyDirectoryPagedAsync_FilterSelectsGroupAndSearchPromotesMatchingTechnician()
{
await using var context = NewContext();
var company = new VendorCompany { Name = "Search Co", NormalizedName = "search co" };
context.VendorCompanies.Add(company);
var older = new Vendor
{
Company = company,
CompanyName = company.Name,
ContactName = "Alice Plumber",
TradeSpecialties = "Plumbing",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 201 } }
};
var newer = new Vendor
{
Company = company,
CompanyName = company.Name,
ContactName = "Zed HVAC",
TradeSpecialties = "HVAC",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 202 } }
};
var inactive = new Vendor
{
Company = company,
CompanyName = company.Name,
ContactName = "Inactive Tech",
TradeSpecialties = "Roofing",
IsActive = false,
IsDeleted = false,
Dispatches = new List<Dispatch> { new() { WorkOrderId = 203 } }
};
context.Vendors.AddRange(older, newer, inactive);
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var search = await service.GetCompanyDirectoryPagedAsync(1, 10, search: "Alice", isActive: true);
var trade = await service.GetCompanyDirectoryPagedAsync(
1, 10, isActive: true, trades: new[] { "Plumbing" });
search.TotalCount.Should().Be(1);
search.Items.Single().Id.Should().Be(older.Id);
search.Items.Single().ContactName.Should().Be("Alice Plumber");
search.Items.Single().Technicians.Should().HaveCount(2);
search.Items.Single().TotalJobs.Should().Be(2);
trade.TotalCount.Should().Be(1);
trade.Items.Single().Id.Should().Be(newer.Id);
trade.Items.Single().Technicians.Should().HaveCount(2);
trade.Items.Single().Technicians.Should().NotContain(tech => tech.Id == inactive.Id);
}
[Fact]
public void BuildCompanyDirectoryKeysQuery_TranslatesGroupingAndFacetFiltersToSqlServer()
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildCompanyDirectoryKeysQuery(
search: "alice",
trades: new[] { "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50" }).ToQueryString();
sql.Should().Contain("GROUP BY");
sql.Should().Contain("LEFT JOIN");
sql.Should().Contain("TradeSpecialties");
sql.Should().Contain("TotalJobs");
Regex.IsMatch(sql, @"SUM\s*\(\s*\(\s*SELECT", RegexOptions.IgnoreCase)
.Should().BeFalse("company totals must sum pre-aggregated scalar job counts");
}
[Fact]
public async Task GetDirectoryPagedAsync_IncludesCompanyOnlyRowsAndPreservesTechnicianRows()
{
await using var context = NewContext();
var soloCompany = new VendorCompany { Name = "Solo Co", NormalizedName = "solo co", Notes = "company notes" };
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
context.VendorCompanies.AddRange(soloCompany, techCompany);
context.Vendors.AddRange(
new Vendor
{
CompanyName = "Tech Co",
ContactName = "Pat",
City = "Austin",
State = "TX",
IsActive = true,
IsDeleted = false,
Company = techCompany
},
new Vendor
{
CompanyName = "Other",
ContactName = "No Company Tech",
IsActive = true,
IsDeleted = false,
CompanyId = null
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true);
totalCount.Should().Be(3);
var companyOnly = items.Single(i => i.CompanyName == "Solo Co");
companyOnly.VendorId.Should().BeNull();
companyOnly.CompanyId.Should().Be(soloCompany.Id);
companyOnly.Notes.Should().Be("company notes");
items.Should().Contain(i => i.ContactName == "Pat" && i.VendorId != null && i.CompanyId == techCompany.Id);
items.Should().Contain(i => i.ContactName == "No Company Tech" && i.VendorId != null);
}
[Fact]
public async Task GetDirectoryPagedAsync_UsesCanonicalCompanyLocationAndEmailFallback()
{
await using var context = NewContext();
var company = new VendorCompany
{
Name = "Canonical Co",
NormalizedName = "canonical co",
Email = "dispatch@canonical.example",
City = "Chicago",
State = "IL"
};
context.VendorCompanies.Add(company);
context.Vendors.AddRange(
new Vendor
{
CompanyName = "Canonical Co",
ContactName = "Company Fallback",
Email = " ",
City = "Stale City",
State = "TX",
IsActive = true,
IsDeleted = false,
Company = company
},
new Vendor
{
CompanyName = "Canonical Co",
ContactName = "Technician Email",
Email = "technician@canonical.example",
IsActive = true,
IsDeleted = false,
Company = company
},
new Vendor
{
CompanyName = "Legacy Co",
ContactName = "Unlinked Technician",
Email = "legacy@example.com",
City = "Austin",
State = "TX",
IsActive = true,
IsDeleted = false,
CompanyId = null
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true);
totalCount.Should().Be(3);
var companyFallback = items.Single(i => i.ContactName == "Company Fallback");
companyFallback.Email.Should().Be("dispatch@canonical.example");
companyFallback.City.Should().Be("Chicago");
companyFallback.State.Should().Be("IL");
var technicianEmail = items.Single(i => i.ContactName == "Technician Email");
technicianEmail.Email.Should().Be("technician@canonical.example");
technicianEmail.City.Should().Be("Chicago");
technicianEmail.State.Should().Be("IL");
var legacy = items.Single(i => i.ContactName == "Unlinked Technician");
legacy.Email.Should().Be("legacy@example.com");
legacy.City.Should().Be("Austin");
legacy.State.Should().Be("TX");
}
[Fact]
public async Task GetDirectoryPagedAsync_SearchAndLocationFilterUseCanonicalCompanyLocation()
{
await using var context = NewContext();
var company = new VendorCompany
{
Name = "Searchable Co",
NormalizedName = "searchable co",
City = "Chicago",
State = null
};
context.VendorCompanies.Add(company);
context.Vendors.Add(new Vendor
{
CompanyName = "Searchable Co",
ContactName = "Pat",
City = "Stale City",
State = "TX",
IsActive = true,
IsDeleted = false,
Company = company
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var search = await service.GetDirectoryPagedAsync(1, 50, search: "chicago", isActive: true);
var location = await service.GetDirectoryPagedAsync(
1,
50,
isActive: true,
locations: new[] { "Chicago" });
var searchRow = search.Items.Should().ContainSingle(i => i.ContactName == "Pat").Subject;
searchRow.City.Should().Be("Chicago");
searchRow.State.Should().BeNull();
var locationRow = location.Items.Should().ContainSingle(i => i.ContactName == "Pat").Subject;
locationRow.City.Should().Be("Chicago");
locationRow.State.Should().BeNull();
}
[Fact]
public async Task GetDirectoryPagedAsync_UsesCanonicalCompanyAddressAndCompanyPhoneWithVendorFallback()
{
await using var context = NewContext();
var fullCompany = new VendorCompany
{
Name = "Full Co",
NormalizedName = "full co",
Address = "100 Company Way",
CompanyPhone = "(555) 111-2222"
};
var blankCompany = new VendorCompany
{
Name = "Blank Co",
NormalizedName = "blank co",
Address = " ",
CompanyPhone = ""
};
context.VendorCompanies.AddRange(fullCompany, blankCompany);
context.Vendors.AddRange(
new Vendor
{
CompanyName = "Full Co",
ContactName = "Full Tech",
Phone = "(555) 222-3333",
CompanyPhone = "(555) 999-0000",
Address = "9 Old Street",
IsActive = true,
IsDeleted = false,
Company = fullCompany
},
new Vendor
{
CompanyName = "Blank Co",
ContactName = "Blank Tech",
CompanyPhone = "(555) 333-4444",
Address = "200 Vendor Way",
IsActive = true,
IsDeleted = false,
Company = blankCompany
},
new Vendor
{
CompanyName = "Legacy Co",
ContactName = "Unlinked Tech",
CompanyPhone = "(555) 777-8888",
Address = "300 Legacy Way",
IsActive = true,
IsDeleted = false,
CompanyId = null
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(1, 50, isActive: true);
totalCount.Should().Be(3);
var fullTech = items.Single(i => i.ContactName == "Full Tech");
fullTech.Address.Should().Be("100 Company Way");
fullTech.CompanyPhone.Should().Be("(555) 111-2222");
fullTech.Phone.Should().Be("(555) 222-3333");
var blankTech = items.Single(i => i.ContactName == "Blank Tech");
blankTech.Address.Should().Be("200 Vendor Way");
blankTech.CompanyPhone.Should().Be("(555) 333-4444");
var legacy = items.Single(i => i.ContactName == "Unlinked Tech");
legacy.Address.Should().Be("300 Legacy Way");
legacy.CompanyPhone.Should().Be("(555) 777-8888");
}
[Fact]
public async Task GetDirectoryPagedAsync_SearchIncludesProjectedAddress()
{
await using var context = NewContext();
var company = new VendorCompany
{
Name = "Addr Co",
NormalizedName = "addr co",
Address = "123 Main St"
};
context.VendorCompanies.Add(company);
context.Vendors.Add(new Vendor
{
CompanyName = "Addr Co",
ContactName = "Pat",
Address = "9 Old Street",
City = "Oldtown",
State = "OT",
IsActive = true,
IsDeleted = false,
Company = company
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var search = await service.GetDirectoryPagedAsync(1, 50, search: "123 main", isActive: true);
search.TotalCount.Should().Be(1);
search.Items.Single().ContactName.Should().Be("Pat");
var stale = await service.GetDirectoryPagedAsync(1, 50, search: "9 old street", isActive: true);
stale.TotalCount.Should().Be(0);
var staleCity = await service.GetDirectoryPagedAsync(1, 50, search: "oldtown", isActive: true);
staleCity.TotalCount.Should().Be(0);
}
[Fact]
public async Task GetDirectoryPagedAsync_LocationFilterFallsBackToExactAddressWhenCityStateMissing()
{
await using var context = NewContext();
var addressOnlyCompany = new VendorCompany
{
Name = "Addr Only Co",
NormalizedName = "addr only co",
Address = " 123 Main St "
};
var soloCompany = new VendorCompany
{
Name = "Solo Co",
NormalizedName = "solo co",
Address = "500 Solo Way"
};
context.VendorCompanies.AddRange(addressOnlyCompany, soloCompany);
context.Vendors.Add(new Vendor
{
CompanyName = "Addr Only Co",
ContactName = "Pat",
City = "Oldtown",
State = "OT",
IsActive = true,
IsDeleted = false,
Company = addressOnlyCompany
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var technicianRow = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "123 Main St" });
technicianRow.TotalCount.Should().Be(1);
technicianRow.Items.Single().ContactName.Should().Be("Pat");
var companyOnlyRow = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "500 Solo Way" });
companyOnlyRow.TotalCount.Should().Be(1);
companyOnlyRow.Items.Single().CompanyName.Should().Be("Solo Co");
var partial = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "123 Main" });
partial.TotalCount.Should().Be(0);
var staleLocation = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "Oldtown, OT" });
staleLocation.TotalCount.Should().Be(0);
}
[Fact]
public async Task GetDirectoryPagedAsync_LocationLabelPrefersCityStateOverAddress()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor
{
CompanyName = "City Co",
ContactName = "City Tech",
City = "Austin",
Address = "999 Address Way",
IsActive = true,
IsDeleted = false
},
new Vendor
{
CompanyName = "State Co",
ContactName = "State Tech",
State = "TX",
Address = "888 Address Way",
IsActive = true,
IsDeleted = false
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var byCity = await service.GetDirectoryPagedAsync(1, 50, isActive: true, locations: new[] { "Austin" });
byCity.TotalCount.Should().Be(1);
byCity.Items.Single().CompanyName.Should().Be("City Co");
var byState = await service.GetDirectoryPagedAsync(1, 50, isActive: true, locations: new[] { "TX" });
byState.TotalCount.Should().Be(1);
byState.Items.Single().CompanyName.Should().Be("State Co");
var bySuppressedAddress = await service.GetDirectoryPagedAsync(
1, 50, isActive: true, locations: new[] { "999 Address Way" });
bySuppressedAddress.TotalCount.Should().Be(0);
}
[Theory]
[InlineData("trade")]
[InlineData("location")]
[InlineData("address-location")]
[InlineData("job-bucket")]
public void BuildDirectoryUnionQuery_TranslatesToSqlServerUnion(string filter)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var query = filter switch
{
"trade" => service.BuildDirectoryUnionQuery(trades: new[] { "Plumbing" }),
"location" => service.BuildDirectoryUnionQuery(locations: new[] { "Chicago, IL" }),
"address-location" => service.BuildDirectoryUnionQuery(locations: new[] { "123 Main St" }),
_ => service.BuildDirectoryUnionQuery(jobBuckets: new[] { "under-50" })
};
var sql = query.ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("UNION");
if (filter == "trade")
sql.Should().Contain("TradeSpecialties");
if (filter == "location")
sql.Should().Contain("City");
if (filter == "address-location")
sql.Should().Contain("Address");
if (filter == "job-bucket")
sql.Should().Contain("TotalJobs");
}
[Fact]
public void BuildDirectoryUnionQuery_TranslatesAddressSearchForSqlServer()
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildDirectoryUnionQuery(search: "123 main").ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("UNION");
sql.Should().Contain("Address");
}
[Fact]
public async Task GetCompaniesForFacetsAsync_IncludesZeroTechnicianCompanyForActiveAndNullStatus()
{
await using var context = NewContext();
var rosterService = new VendorCompanyRosterDataService(context);
await rosterService.CreateRosterAsync(new VendorCompanyRosterWriteModel
{
Name = "Solo Co",
Email = "solo@example.com",
ActorUserId = "42",
Technicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
var techCompany = new VendorCompany { Name = "Tech Co", NormalizedName = "tech co" };
context.VendorCompanies.Add(techCompany);
context.Vendors.Add(new Vendor
{
CompanyName = "Tech Co",
ContactName = "Pat",
IsActive = true,
IsDeleted = false,
Company = techCompany
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Solo Co", "Tech Co" });
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Should().BeEmpty();
}
[Fact]
public async Task GetCompaniesForFacetsAsync_InactiveStatusReturnsOnlyCompaniesWithInactiveTechnicians()
{
await using var context = NewContext();
var activeOnlyCo = new VendorCompany { Name = "Active Only Co", NormalizedName = "active only co" };
var inactiveOnlyCo = new VendorCompany { Name = "Inactive Only Co", NormalizedName = "inactive only co" };
var mixedCo = new VendorCompany { Name = "Mixed Co", NormalizedName = "mixed co" };
context.VendorCompanies.AddRange(activeOnlyCo, inactiveOnlyCo, mixedCo);
context.Vendors.AddRange(
new Vendor { CompanyName = "Active Only Co", IsActive = true, IsDeleted = false, Company = activeOnlyCo },
new Vendor { CompanyName = "Inactive Only Co", IsActive = false, IsDeleted = false, Company = inactiveOnlyCo },
new Vendor { CompanyName = "Mixed Co", IsActive = true, IsDeleted = false, Company = mixedCo },
new Vendor { CompanyName = "Mixed Co", IsActive = false, IsDeleted = false, Company = mixedCo });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Inactive Only Co", "Mixed Co" });
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Mixed Co" });
var noStatus = await service.GetCompaniesForFacetsAsync(null, CancellationToken.None);
noStatus.Select(c => c.Name).Should().BeEquivalentTo(new[] { "Active Only Co", "Inactive Only Co", "Mixed Co" });
}
[Fact]
public async Task GetCompaniesForFacetsAsync_TreatsCompanyWithOnlyDeletedTechniciansAsZeroTechnician()
{
await using var context = NewContext();
var company = new VendorCompany { Name = "Ghost Co", NormalizedName = "ghost co" };
context.VendorCompanies.Add(company);
context.Vendors.Add(new Vendor
{
CompanyName = "Ghost Co",
IsActive = true,
IsDeleted = true,
Company = company
});
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetCompaniesForFacetsAsync(true, CancellationToken.None);
active.Single().Name.Should().Be("Ghost Co");
var inactive = await service.GetCompaniesForFacetsAsync(false, CancellationToken.None);
inactive.Should().BeEmpty();
}
[Fact]
public async Task GetActiveTradesAsync_ReturnsEmptyOnEmptyDatabase()
{
await using var context = NewContext();
var service = new VendorDataService(context);
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
trades.Should().BeEmpty();
}
[Fact]
public async Task GetActiveTradesAsync_FiltersInactiveAndOrdersBySortOrderThenName()
{
await using var context = NewContext();
context.Trades.AddRange(
new Trade { Name = "Janitorial", NormalizedName = "janitorial", SortOrder = 7 },
new Trade { Name = "Plumbing", NormalizedName = "plumbing", SortOrder = 1 },
new Trade { Name = "Electrical", NormalizedName = "electrical", SortOrder = 2 },
new Trade { Name = "Retired Trade", NormalizedName = "retired trade", SortOrder = 0, IsActive = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
trades.Select(t => t.Name).Should().Equal("Plumbing", "Electrical", "Janitorial");
}
[Fact]
public async Task GetPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
// SH-249: stored TradeSpecialties stay free text; filtering by a legacy
// value that is not in the canonical catalog must keep matching.
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
trades: new[] { "Backflow Testing" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Legacy Co");
}
[Fact]
public async Task GetDirectoryPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetDirectoryPagedAsync(
1,
12,
isActive: true,
trades: new[] { "Backflow Testing" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Legacy Co");
}
[Theory]
[InlineData(true)]
[InlineData(false)]
[InlineData(null)]
public void BuildCompaniesForFacetsQuery_TranslatesToSqlServerWithoutClientEvaluation(bool? isActive)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildCompaniesForFacetsQuery(isActive).ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("VendorCompanies");
}
}