Files
baya-monorepo/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs
T
2026-08-02 23:12:44 +03:30

258 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}