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:
@@ -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);
|
||||
}
|
||||
+6
-4
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user