using Baya.Application.Models.Search; using Baya.Domain.Entities.Catalog; 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 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().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() .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); }