backend phase 7: search & matching (nurse_search_index)

Add the discovery layer: the denormalized nurse_search_index read model
(one row per bookable variant x covered service area), maintained inline
inside each source write's transaction, plus the single public search
query behind the INurseSearch seam.

- Entity + EF config + migration (search schema): covering search index,
  filtered-unique (variant_id, city_id, district_id) pair with NULL
  district participating, nurse_id index, soft-delete.
- ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex
  variant / nurse / fan-out / remove-area / full rebuild, staged in the
  owning source write's unit of work; wired into the b3/b4/b5/b6 handlers.
- INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only
  is_searchable=1, category/city/district(NULL-aware)/gender/price filters,
  rating sort, pagination. Elasticsearch deferred (config Search:Backend).
- SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public
  SearchController (GET search/nurses) + admin AdminSearchController
  (POST admin_search/rebuild_index).
- Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated
  affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green.
- Docs: server CLAUDE.md project map, contract search.md, swagger refresh,
  handoff, report, mocks-registry rows, STATUS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 17:24:26 +03:30
parent e2b22df2d6
commit 5839b3508f
47 changed files with 5743 additions and 41 deletions
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.Variants.Commands.CreateVariant;
using Baya.Application.Models.Catalog;
using Baya.Domain.Entities.Catalog;
@@ -16,6 +17,7 @@ public class CreateVariantHandlerTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
private const long CategoryId = 1;
private const long ShiftGroupId = 10;
@@ -50,7 +52,7 @@ public class CreateVariantHandlerTests
.Returns(false);
}
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork);
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork, _searchIndex);
private static CreateVariantCommand Command(IReadOnlyList<VariantOptionSelection> options, string? displayName = null)
=> new(CategoryId, options, "8000000", "per_24h", null, displayName);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
using Baya.Domain.Entities.Geography;
@@ -16,6 +17,7 @@ public class NurseServiceAreaHandlersTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly IGeoRepository _geo = Substitute.For<IGeoRepository>();
private readonly INurseServiceAreaRepository _areas = Substitute.For<INurseServiceAreaRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public NurseServiceAreaHandlersTests()
{
@@ -34,7 +36,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_WholeCity_PersistsWholeCityRow()
{
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(false);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -50,7 +52,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted()
{
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(true);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -66,7 +68,7 @@ public class NurseServiceAreaHandlersTests
_geo.GetDistrictAsync(5L, Arg.Any<CancellationToken>())
.Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" });
_areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any<CancellationToken>()).Returns(true);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None);
@@ -77,7 +79,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_DistrictNotInCity_Fails()
{
_geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any<CancellationToken>()).Returns(false);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None);
@@ -89,7 +91,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_NonNurse_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Customer]);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -100,7 +102,7 @@ public class NurseServiceAreaHandlersTests
public async Task Remove_OtherNursesArea_IsNotFound()
{
_areas.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>());
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>(), _searchIndex);
var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Models.Identity;
@@ -15,6 +16,7 @@ public class NurseProfileHandlersTests
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public NurseProfileHandlersTests()
{
@@ -58,7 +60,7 @@ public class NurseProfileHandlersTests
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
@@ -71,7 +73,7 @@ public class NurseProfileHandlersTests
{
var profile = new NurseProfile { UserId = 7 };
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
@@ -0,0 +1,170 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Search;
/// <summary>
/// A self-contained SQLite host for the search-index maintainer + SqlNurseSearch, exercising the real EF
/// model (schema, filtered indexes, query filters) end-to-end. Seeds a shared province/city/two districts
/// and one active service category; <see cref="SeedNurse"/> builds a full nurse (user + profile +
/// verification + variant + area) so a test can drive the maintainer and assert what search returns.
/// </summary>
public sealed class SearchIndexTestHost : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 7, 5, 12, 0, 0, TimeSpan.Zero);
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public ISearchIndexMaintainer Maintainer { get; }
public INurseSearch Search { get; }
public long CategoryId { get; }
public long OtherCategoryId { get; }
public long CityId { get; }
public long District3Id { get; }
public long District7Id { get; }
private int _phoneSeed = 90000000;
public SearchIndexTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_connection)
.Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(Now);
Maintainer = new SearchIndexMaintainer(Db, clock);
Search = new SqlNurseSearch(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
Db.SaveChanges();
CityId = city.Id;
var d3 = new District { CityId = city.Id, NameFa = "منطقه ۳", NameEn = "District 3", SortOrder = 3, IsActive = true };
var d7 = new District { CityId = city.Id, NameFa = "منطقه ۷", NameEn = "District 7", SortOrder = 7, IsActive = true };
Db.Set<District>().AddRange(d3, d7);
Db.SaveChanges();
District3Id = d3.Id;
District7Id = d7.Id;
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
var other = new ServiceCategory { NameFa = "نوزاد", NameEn = "Infant", SortOrder = 2, IsActive = true };
Db.Set<ServiceCategory>().AddRange(category, other);
Db.SaveChanges();
CategoryId = category.Id;
OtherCategoryId = other.Id;
}
public sealed record SeededNurse(long NurseId, NurseProfile Profile, NurseVerification Verification, NurseServiceVariant Variant, NurseServiceArea Area);
/// <summary>Seeds one nurse with a single active variant + a single service area, in a chosen bookability
/// state, and does NOT project it yet (the caller drives the maintainer).</summary>
public SeededNurse SeedNurse(
string gender,
bool verified,
bool accepting,
VerificationStatus status,
long price,
long? districtId,
decimal averageRating = 0m,
int totalReviews = 0,
long? categoryId = null,
bool variantActive = true,
string priceUnit = "per_day")
{
var user = new User
{
UserName = $"nurse{_phoneSeed}",
PhoneNumber = $"0912{_phoneSeed++}",
Gender = gender,
IsActive = true
};
Db.Users.Add(user);
Db.SaveChanges();
var profile = new NurseProfile { UserId = user.Id };
if (verified)
profile.MarkVerified();
profile.SetAcceptingBookings(accepting);
Db.Set<NurseProfile>().Add(profile);
SetAggregates(profile, averageRating, totalReviews);
Db.SaveChanges();
var verification = new NurseVerification { NurseId = profile.Id, Status = status };
Db.Set<NurseVerification>().Add(verification);
Db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = profile.Id,
ServiceCategoryId = categoryId ?? CategoryId,
Price = price,
PriceUnit = priceUnit,
SessionCount = null,
DisplayName = "variant",
OptionSetHash = $"hash-{profile.Id}",
IsActive = variantActive
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var area = new NurseServiceArea { NurseId = profile.Id, CityId = CityId, DistrictId = districtId, IsActive = true };
Db.Set<NurseServiceArea>().Add(area);
Db.SaveChanges();
return new SeededNurse(profile.Id, profile, verification, variant, area);
}
// The aggregate setters on NurseProfile are private (recomputed by b9/b14). Tests seed them via EF's
// backing fields so a nurse can carry a rating without the (not-yet-built) review pipeline.
private void SetAggregates(NurseProfile profile, decimal averageRating, int totalReviews)
{
var entry = Db.Entry(profile);
entry.Property(nameof(NurseProfile.AverageRating)).CurrentValue = averageRating;
entry.Property(nameof(NurseProfile.TotalReviews)).CurrentValue = totalReviews;
}
/// <summary>Adds a real service-area row (as the b4 handler would) so a later fan-out and a full rebuild
/// derive from the same source.</summary>
public NurseServiceArea AddArea(long nurseId, long? districtId)
{
var area = new NurseServiceArea { NurseId = nurseId, CityId = CityId, DistrictId = districtId, IsActive = true };
Db.Set<NurseServiceArea>().Add(area);
Db.SaveChanges();
return area;
}
public int LiveRowCount() => Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count();
public int SearchableRowCount() =>
Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count(r => r.IsSearchable);
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,219 @@
using Baya.Application.Models.Search;
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 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);
}
@@ -1,6 +1,7 @@
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.ReviewStep;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
@@ -25,6 +26,7 @@ public class AdminVerificationHandlersTests
private readonly ICacheService _cache = Substitute.For<ICacheService>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public AdminVerificationHandlersTests()
{
@@ -44,7 +46,7 @@ public class AdminVerificationHandlersTests
}
private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier)
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock);
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock, _searchIndex);
[Fact]
public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified()
@@ -121,7 +123,7 @@ public class AdminVerificationHandlersTests
var profile = new NurseProfile();
profile.MarkVerified();
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock);
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock, _searchIndex);
var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None);
@@ -151,7 +153,7 @@ public class AdminVerificationHandlersTests
var alerts = Substitute.For<ISupportAlertService>();
var notifications = Substitute.For<INotificationDispatcher>();
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock);
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock, _searchIndex);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
@@ -193,7 +195,7 @@ public class AdminVerificationHandlersTests
_nurses.GetTrackedByIdAsync(99, Arg.Any<CancellationToken>()).Returns(profileB);
var handler = new ScanExpiringCredentialsCommandHandler(
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock);
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock, _searchIndex);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
@@ -24,6 +25,7 @@ public class RunStepHandlersTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public RunStepHandlersTests()
{
@@ -54,7 +56,7 @@ public class RunStepHandlersTests
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0012345678", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None);
@@ -75,7 +77,7 @@ public class RunStepHandlersTests
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0000000000", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify"));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None);
@@ -96,7 +98,7 @@ public class RunStepHandlersTests
shahkar.MatchAsync("09120000000", "0012345678", Arg.Any<CancellationToken>())
.Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim"));
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
@@ -115,7 +117,7 @@ public class RunStepHandlersTests
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(new User { PhoneNumber = "09121112233" });
var shahkar = Substitute.For<IShahkarVerifier>();
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
@@ -135,7 +137,7 @@ public class RunStepHandlersTests
var verifier = Substitute.For<IBankAccountOwnershipVerifier>();
verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(false, "Someone Else", "ref"));
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock);
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock, _searchIndex);
var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);