using Baya.Application.Models.Search;
using Baya.Domain.Entities.Verification;
namespace Baya.Test.Foundation.Search;
///
/// 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.
///
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 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()
.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().Any(r => r.NurseId == nurseId && r.IsSearchable);
}