258 lines
12 KiB
C#
258 lines
12 KiB
C#
using Baya.Application.Models.Search;
|
||
using Baya.Domain.Entities.Catalog;
|
||
using Baya.Domain.Entities.Verification;
|
||
|
||
namespace Baya.Test.Foundation.Search;
|
||
|
||
/// <summary>
|
||
/// End-to-end coverage of the search-index maintainer + SqlNurseSearch over a real EF/SQLite model: the
|
||
/// is_searchable predicate, NULL-district geography, gender/price filters, rating sort, the verification
|
||
/// flip, service-area fan-out/remove, variant deactivate, and incremental↔rebuild convergence.
|
||
/// </summary>
|
||
public sealed class SearchIndexTests
|
||
{
|
||
private const string Female = "female";
|
||
private const string Male = "male";
|
||
|
||
private static NurseSearchCriteria Criteria(
|
||
long categoryId, long cityId, long? districtId = null, string? gender = null,
|
||
long? minPrice = null, long? maxPrice = null, string? priceUnit = null, int page = 1, int pageSize = 50)
|
||
=> new(categoryId, cityId, districtId, gender, minPrice, maxPrice, priceUnit, page, pageSize);
|
||
|
||
private static void Project(SearchIndexTestHost host, SearchIndexTestHost.SeededNurse nurse)
|
||
{
|
||
host.Maintainer.ReindexNurseAsync(nurse.Profile, nurse.Verification.Status, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
}
|
||
|
||
[Fact]
|
||
public void IsSearchable_TrueOnlyWhenVerifiedAcceptingNotSuspendedAndVariantActive()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
|
||
var good = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
var unverified = host.SeedNurse(Female, verified: false, accepting: true, VerificationStatus.Pending, 1000, host.District3Id);
|
||
var notAccepting = host.SeedNurse(Female, verified: true, accepting: false, VerificationStatus.Approved, 1000, host.District3Id);
|
||
var suspended = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Suspended, 1000, host.District3Id);
|
||
var inactiveVariant = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id, variantActive: false);
|
||
|
||
foreach (var n in new[] { good, unverified, notAccepting, suspended, inactiveVariant })
|
||
Project(host, n);
|
||
|
||
Assert.True(IsSearchable(host, good.NurseId));
|
||
Assert.False(IsSearchable(host, unverified.NurseId));
|
||
Assert.False(IsSearchable(host, notAccepting.NurseId));
|
||
Assert.False(IsSearchable(host, suspended.NurseId));
|
||
Assert.False(IsSearchable(host, inactiveVariant.NurseId));
|
||
|
||
// Every nurse with a variant + area has an index row, but only the fully-bookable one is searchable.
|
||
Assert.Equal(5, host.LiveRowCount());
|
||
Assert.Equal(1, host.SearchableRowCount());
|
||
}
|
||
|
||
[Fact]
|
||
public void Geography_WholeCityAndDistrictMatchingIsExact()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
|
||
var district3 = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
var wholeCity = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, districtId: null);
|
||
Project(host, district3);
|
||
Project(host, wholeCity);
|
||
|
||
// District-3 search: the district-3 nurse AND the whole-city (NULL) nurse.
|
||
var d3 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result;
|
||
Assert.Equal(new[] { district3.NurseId, wholeCity.NurseId }.OrderBy(x => x), d3.Items.Select(i => i.NurseId).OrderBy(x => x));
|
||
|
||
// A different district in the same city: only the whole-city nurse.
|
||
var d7 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result;
|
||
Assert.Equal(new[] { wholeCity.NurseId }, d7.Items.Select(i => i.NurseId).ToArray());
|
||
|
||
// City-only search (no district): both.
|
||
var city = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
|
||
Assert.Equal(2, city.Total);
|
||
}
|
||
|
||
[Fact]
|
||
public void SameGenderFilterNarrowsResults()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var female = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
var male = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
Project(host, female);
|
||
Project(host, male);
|
||
|
||
var females = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Female), default).Result;
|
||
Assert.Equal(new[] { female.NurseId }, females.Items.Select(i => i.NurseId).ToArray());
|
||
|
||
var males = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Male), default).Result;
|
||
Assert.Equal(new[] { male.NurseId }, males.Items.Select(i => i.NurseId).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public void PriceRangeFiltersOnCopiedIrrPrice()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var cheap = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 500_000, host.District3Id);
|
||
var pricey = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 5_000_000, host.District3Id);
|
||
Project(host, cheap);
|
||
Project(host, pricey);
|
||
|
||
var midBand = host.Search.SearchAsync(
|
||
Criteria(host.CategoryId, host.CityId, minPrice: 400_000, maxPrice: 1_000_000), default).Result;
|
||
|
||
Assert.Equal(new[] { cheap.NurseId }, midBand.Items.Select(i => i.NurseId).ToArray());
|
||
Assert.Equal("500000", midBand.Items[0].Price);
|
||
}
|
||
|
||
[Fact]
|
||
public void ResultsSortByRatingDescending()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var low = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 3.1m, totalReviews: 4);
|
||
var high = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.8m, totalReviews: 9);
|
||
Project(host, low);
|
||
Project(host, high);
|
||
|
||
var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
|
||
|
||
Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public void SameNurseMultipleMatchingVariants_CollapsesToOneCardWithCheapestPriceAndMatchCount()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 2000, host.District3Id);
|
||
Project(host, nurse);
|
||
|
||
// A second, cheaper variant in the same category — the maintainer fans it out across the nurse's
|
||
// existing areas, so it also becomes a match for the same search.
|
||
var cheaper = new NurseServiceVariant
|
||
{
|
||
NurseId = nurse.NurseId,
|
||
ServiceCategoryId = host.CategoryId,
|
||
Price = 1000,
|
||
PriceUnit = "per_day",
|
||
SessionCount = null,
|
||
DisplayName = "cheaper",
|
||
OptionSetHash = $"hash-{nurse.NurseId}-2",
|
||
IsActive = true
|
||
};
|
||
host.Db.Set<NurseServiceVariant>().Add(cheaper);
|
||
host.Db.SaveChanges();
|
||
host.Maintainer.ReindexVariantAsync(cheaper, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
|
||
// Two searchable rows for the one nurse, but the search must collapse them to one card.
|
||
Assert.Equal(2, host.SearchableRowCount());
|
||
|
||
var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
|
||
|
||
Assert.Single(page.Items);
|
||
Assert.Equal(1, page.Total);
|
||
Assert.Equal(nurse.NurseId, page.Items[0].NurseId);
|
||
Assert.Equal("1000", page.Items[0].Price); // the cheaper matching variant is the representative
|
||
Assert.Equal(2, page.Items[0].MatchingServiceCount);
|
||
}
|
||
|
||
[Fact]
|
||
public void SuspendingANurseRemovesThemFromSearch()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
Project(host, nurse);
|
||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||
|
||
// Flip to suspended + unverified (the b6 suspend path) and reindex in place.
|
||
nurse.Profile.MarkUnverified();
|
||
nurse.Verification.Status = VerificationStatus.Suspended;
|
||
Project(host, nurse);
|
||
|
||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||
Assert.False(IsSearchable(host, nurse.NurseId));
|
||
|
||
// Reinstating makes them searchable again — the row is resurrected, not duplicated.
|
||
nurse.Profile.MarkVerified();
|
||
nurse.Verification.Status = VerificationStatus.Approved;
|
||
Project(host, nurse);
|
||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||
Assert.Equal(1, host.LiveRowCount());
|
||
}
|
||
|
||
[Fact]
|
||
public void FanOutAddsAreaRows_RemoveDropsExactlyThoseRows()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
Project(host, nurse);
|
||
Assert.Equal(1, host.LiveRowCount());
|
||
|
||
// Add a second area (district 7) and fan out.
|
||
host.Maintainer.FanOutServiceAreaAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
Assert.Equal(2, host.SearchableRowCount());
|
||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
|
||
|
||
// Remove the district-7 area: only its rows drop; district 3 stays.
|
||
host.Maintainer.RemoveServiceAreaRowsAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
|
||
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result.Items);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeactivatingAVariantKeepsRowsButHidesThem()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
|
||
Project(host, nurse);
|
||
Assert.Equal(1, host.SearchableRowCount());
|
||
|
||
nurse.Variant.IsActive = false;
|
||
host.Maintainer.ReindexVariantAsync(nurse.Variant, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
|
||
Assert.Equal(1, host.LiveRowCount()); // row kept
|
||
Assert.Equal(0, host.SearchableRowCount()); // but not searchable
|
||
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
|
||
}
|
||
|
||
[Fact]
|
||
public void IncrementalMaintenanceConvergesWithFullRebuild()
|
||
{
|
||
using var host = new SearchIndexTestHost();
|
||
|
||
var a = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.5m);
|
||
var b = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 2000, districtId: null);
|
||
var c = host.SeedNurse(Female, false, true, VerificationStatus.Pending, 3000, host.District7Id);
|
||
Project(host, a);
|
||
Project(host, b);
|
||
Project(host, c);
|
||
// Add a second area to B incrementally — the real service-area row plus the fan-out, as b4 does.
|
||
host.AddArea(b.NurseId, host.District3Id);
|
||
host.Maintainer.FanOutServiceAreaAsync(b.NurseId, host.CityId, host.District3Id, default).GetAwaiter().GetResult();
|
||
host.Db.SaveChanges();
|
||
|
||
var incrementalLive = host.LiveRowCount();
|
||
var incrementalSearchable = host.SearchableRowCount();
|
||
|
||
// A full rebuild from source must reproduce the same live/searchable row set (convergence).
|
||
var result = host.Maintainer.RebuildAsync(default).GetAwaiter().GetResult();
|
||
|
||
Assert.Equal(incrementalLive, host.LiveRowCount());
|
||
Assert.Equal(incrementalSearchable, host.SearchableRowCount());
|
||
Assert.Equal(3, result.NursesProcessed);
|
||
Assert.Equal(incrementalLive, result.RowsWritten);
|
||
|
||
// No duplicate (variant × area) rows after rebuild.
|
||
var duplicates = host.Db.Set<Domain.Entities.Search.NurseSearchIndex>()
|
||
.GroupBy(r => new { r.VariantId, r.CityId, r.DistrictId })
|
||
.Any(g => g.Count() > 1);
|
||
Assert.False(duplicates);
|
||
}
|
||
|
||
private static bool IsSearchable(SearchIndexTestHost host, long nurseId)
|
||
=> host.Db.Set<Domain.Entities.Search.NurseSearchIndex>().Any(r => r.NurseId == nurseId && r.IsSearchable);
|
||
}
|