refinement phase 1

This commit is contained in:
hamid
2026-07-13 00:49:24 +03:30
parent 7acecda5c4
commit 0b45ec51f4
11 changed files with 856 additions and 6 deletions
+12 -2
View File
@@ -56,8 +56,9 @@ You are a **senior .NET software engineer** working on this codebase. That means
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL
Server is required to start.
On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()`
— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below).
A reachable SQL Server is required to start.
---
@@ -571,6 +572,15 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one
cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
verified) is in `dev/post-phase/refinement/RUNBOOK.md`.
---
+5
View File
@@ -107,6 +107,11 @@ if (!app.Environment.IsEnvironment("Testing"))
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
if (app.Environment.IsDevelopment())
await app.SeedDemoWorldAsync();
}
if (app.Environment.IsDevelopment())
@@ -15,6 +15,7 @@
<ItemGroup>
<InternalsVisibleTo Include="Baya.Test.Foundation" />
<InternalsVisibleTo Include="Baya.Test.Api" />
</ItemGroup>
<ItemGroup>
@@ -19,6 +19,7 @@ using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.Seeding;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
@@ -78,6 +79,10 @@ public static class ServiceCollectionExtensions
throw new NotSupportedException(
$"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred).");
// Development-only demo marketplace seeder. Registered always (cheap), but invoked from Program.cs
// only under IsDevelopment() — never against a production/staging DB.
services.AddScoped<DemoWorldSeeder>();
return services;
}
@@ -118,4 +123,18 @@ public static class ServiceCollectionExtensions
await context.SaveChangesAsync();
}
/// <summary>
/// Seeds a coherent <b>Development-only</b> demo marketplace (verified/unverified nurses with variants +
/// Tehran coverage, demo customers with patients + addresses) on top of the reference seeds, so every
/// real-path discovery/search/booking screen has data. Idempotent — guarded on each persona's phone —
/// and drives the search projection through the real maintainer. Callers must gate this on
/// <c>IsDevelopment()</c>: seeding fake accounts into a real DB would be a trust/data-integrity disaster.
/// </summary>
public static async Task SeedDemoWorldAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
await seeder.SeedAsync(CancellationToken.None);
}
}
@@ -0,0 +1,231 @@
#nullable enable
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
namespace Baya.Infrastructure.Persistence.Services.Seeding;
/// <summary>
/// The static, deterministic description of the Development demo marketplace the <see cref="DemoWorldSeeder"/>
/// materialises. Kept as plain data (not inline in the seeder) so the exact demo world — the phone numbers a
/// developer logs in with, which nurse is verified, the priced variants and coverage — reads at a glance and
/// stays the single source of truth for the runbook / frontend de-mock hand-off.
/// <para>
/// All money is <b>IRR Rials as an integer</b> (no Toman, no float). Category ids are the fixed catalog seed
/// ids (<c>1…5</c>); the Tehran city id and district ids are the fixed geography seed ids.
/// </para>
/// </summary>
internal static class DemoWorldDefinitions
{
public const long TehranCityId = GeographySeed.TehranCityId;
// Tehran municipal district ids from the geography seed (1000 + n).
public const long District1 = 1001;
public const long District2 = 1002;
public const long District3 = 1003;
public const long District6 = 1006;
public const long District12 = 1012;
// Catalog category ids from the catalog seed.
public const long ElderlyCare = 1;
public const long PostSurgery = 2;
public const long InfantCare = 3;
public const long ChronicIllness = 4;
/// <summary>The single cross-category, required demo pricing dimension (شیفت / shift type). Seeding it in
/// Development gives the frontend variant builder a required option-group to render on the real path
/// (the "fresh catalog has categories but no option groups" data gap) without polluting a production DB.</summary>
public const string ShiftGroupNameEn = "Shift Type";
public const string ShiftGroupNameFa = "نوع شیفت";
public const string ShiftDaytime = "Daytime";
public const string ShiftNight = "Night";
public const string ShiftLiveIn = "Live-in";
public static readonly (string NameFa, string NameEn)[] ShiftValues =
[
("روزانه", ShiftDaytime),
("شبانه", ShiftNight),
("شبانه‌روزی", ShiftLiveIn),
];
public static readonly NursePersona[] Nurses =
[
new NursePersona(
Phone: "09120000001",
UserName: "demo_nurse_azizi",
Name: "زهرا",
FamilyName: "عزیزی",
Gender: "female",
NationalId: "0012345678",
Bio: "پرستار سالمند با هشت سال سابقه مراقبت در منزل.",
YearsOfExperience: 8,
IsVerified: true,
Credentials:
[
new CredentialDef(CredentialTypes.MohCompetencyLicense, "پروانه صلاحیت وزارت بهداشت", "MOH-84512"),
new CredentialDef(CredentialTypes.CriminalRecord, "گواهی عدم سوء پیشینه", "CR-2025-1187"),
],
Bank: new BankDef("بانک ملت", "زهرا عزیزی", "IR820170000000123456789012", MatchedNationalId: true),
Variants:
[
new VariantDef(ElderlyCare, PriceUnits.Per24H, 3_500_000, SessionCount: null, ShiftLiveIn, "مراقبت شبانه‌روزی سالمند"),
new VariantDef(ElderlyCare, PriceUnits.PerHour, 250_000, SessionCount: null, ShiftDaytime, "مراقبت ساعتی سالمند"),
new VariantDef(PostSurgery, PriceUnits.PerDay, 2_000_000, SessionCount: null, ShiftDaytime, "مراقبت روزانه پس از جراحی"),
],
Areas:
[
new AreaDef(TehranCityId, null), // whole-city Tehran
new AreaDef(TehranCityId, District1),
new AreaDef(TehranCityId, District3),
]),
new NursePersona(
Phone: "09120000002",
UserName: "demo_nurse_karimi",
Name: "علی",
FamilyName: "کریمی",
Gender: "male",
NationalId: "0023456789",
Bio: "پرستار مراقبت پس از جراحی و بیماری‌های مزمن.",
YearsOfExperience: 5,
IsVerified: true,
Credentials:
[
new CredentialDef(CredentialTypes.MohCompetencyLicense, "پروانه صلاحیت وزارت بهداشت", "MOH-77120"),
],
Bank: new BankDef("بانک ملی", "علی کریمی", "IR330120000000098765432101", MatchedNationalId: true),
Variants:
[
new VariantDef(ChronicIllness, PriceUnits.PerDay, 1_800_000, SessionCount: null, ShiftDaytime, "مدیریت روزانه بیماری مزمن"),
new VariantDef(PostSurgery, PriceUnits.Per24H, 3_200_000, SessionCount: null, ShiftLiveIn, "مراقبت شبانه‌روزی پس از جراحی"),
],
Areas:
[
new AreaDef(TehranCityId, District3),
new AreaDef(TehranCityId, District6),
new AreaDef(TehranCityId, District12),
]),
// Unverified: has a profile + a variant + a covered area, but is_verified=0 so the search-index
// maintainer computes is_searchable=0 and this nurse must never surface in discovery.
new NursePersona(
Phone: "09120000003",
UserName: "demo_nurse_ahmadi",
Name: "مریم",
FamilyName: "احمدی",
Gender: "female",
NationalId: null,
Bio: "در انتظار تکمیل مراحل احراز هویت.",
YearsOfExperience: 2,
IsVerified: false,
Credentials: [],
Bank: null,
Variants:
[
new VariantDef(InfantCare, PriceUnits.PerSession, 800_000, SessionCount: 1, ShiftDaytime, "مراقبت جلسه‌ای نوزاد"),
],
Areas:
[
new AreaDef(TehranCityId, District2),
]),
];
public static readonly CustomerPersona[] Customers =
[
new CustomerPersona(
Phone: "09120000010",
UserName: "demo_customer_mohammadi",
Name: "سارا",
FamilyName: "محمدی",
Gender: "female",
EmergencyContactName: "بهرام محمدی",
EmergencyContactPhone: "09121110010",
Patients:
[
new PatientDef("حسن محمدی", "حسن", "محمدی", "male", new DateOnly(1948, 3, 15), "A+", "سابقه فشار خون و دیابت."),
new PatientDef("فاطمه محمدی", "فاطمه", "محمدی", "female", new DateOnly(1955, 9, 2), "O+", "بدون سابقه بیماری خاص."),
],
Addresses:
[
new AddressDef("خانه", District3, "تهران، ونک، خیابان ملاصدرا، پلاک ۱۲", "1991834511", "بهرام محمدی", "09121110010", 35.7595m, 51.4100m, IsPrimary: true),
]),
new CustomerPersona(
Phone: "09120000011",
UserName: "demo_customer_hosseini",
Name: "رضا",
FamilyName: "حسینی",
Gender: "male",
EmergencyContactName: "نازنین حسینی",
EmergencyContactPhone: "09121110011",
Patients:
[
new PatientDef("امیرعلی حسینی", "امیرعلی", "حسینی", "male", new DateOnly(2025, 1, 20), "B+", "نوزاد سالم، نیازمند مراقبت روزانه."),
],
Addresses:
[
new AddressDef("خانه", District6, "تهران، سعادت‌آباد، بلوار دریا، پلاک ۴۵", "1998765432", "نازنین حسینی", "09121110011", 35.7870m, 51.3760m, IsPrimary: true),
]),
];
}
internal sealed record NursePersona(
string Phone,
string UserName,
string Name,
string FamilyName,
string Gender,
string? NationalId,
string Bio,
int YearsOfExperience,
bool IsVerified,
CredentialDef[] Credentials,
BankDef? Bank,
VariantDef[] Variants,
AreaDef[] Areas);
internal sealed record CredentialDef(string Type, string IssuingAuthority, string Number);
internal sealed record BankDef(string BankName, string AccountHolderName, string Iban, bool MatchedNationalId);
internal sealed record VariantDef(
long CategoryId,
string PriceUnit,
long Price,
int? SessionCount,
string ShiftValueEn,
string DisplayName);
internal sealed record AreaDef(long CityId, long? DistrictId);
internal sealed record CustomerPersona(
string Phone,
string UserName,
string Name,
string FamilyName,
string Gender,
string EmergencyContactName,
string EmergencyContactPhone,
PatientDef[] Patients,
AddressDef[] Addresses);
internal sealed record PatientDef(
string DisplayName,
string FirstName,
string LastName,
string Gender,
DateOnly BirthDate,
string BloodType,
string InitialMedicalNotes);
internal sealed record AddressDef(
string Title,
long DistrictId,
string AddressLine,
string PostalCode,
string RecipientName,
string RecipientPhone,
decimal Latitude,
decimal Longitude,
bool IsPrimary);
@@ -0,0 +1,293 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Identity;
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 Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Seeding;
/// <summary>
/// Development-only seeder that turns a freshly-migrated database (reference/lookup rows + one admin + one
/// gateway) into a <b>populated demo marketplace</b>: verified nurses with priced variants and Tehran
/// coverage, an unverified nurse, and demo customers with patients and addresses. Without it every real-path
/// discovery/search/booking screen renders empty because the reference seed contains no transactional data.
/// <para>
/// It seeds the <i>causes</i> (verification flip, active variants, covered areas) and lets the real
/// <see cref="ISearchIndexMaintainer"/> compute the <c>nurse_search_index</c> — never hand-inserting index
/// rows — so the seeded world is identical to one built by real usage and the <c>is_searchable</c> invariant
/// stays truthful. Every insert is guarded on a stable natural key (the persona's phone number, the option
/// group name), so re-running on an already-seeded DB is a no-op.
/// </para>
/// </summary>
internal sealed class DemoWorldSeeder(
ApplicationDbContext db,
IAppUserManager userManager,
ISearchIndexMaintainer searchIndex,
IFieldEncryptor encryptor,
IDateTimeProvider clock,
ILogger<DemoWorldSeeder> logger)
{
public async Task SeedAsync(CancellationToken cancellationToken)
{
var (shiftGroupId, shiftValueIdByCode) = await EnsureShiftTypeGroupAsync(cancellationToken);
var seededNurses = 0;
foreach (var persona in DemoWorldDefinitions.Nurses)
if (await EnsureNurseAsync(persona, shiftGroupId, shiftValueIdByCode, cancellationToken))
seededNurses++;
var seededCustomers = 0;
foreach (var persona in DemoWorldDefinitions.Customers)
if (await EnsureCustomerAsync(persona, cancellationToken))
seededCustomers++;
// Re-derive the whole search projection from source. Idempotent (drops + rebuilds), and the single
// place is_searchable is computed — verified+accepting nurses with an active variant surface; the
// unverified nurse does not.
var rebuild = await searchIndex.RebuildAsync(cancellationToken);
if (seededNurses == 0 && seededCustomers == 0)
logger.LogInformation(
"Demo world already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
rebuild.NursesProcessed, rebuild.RowsWritten);
else
logger.LogInformation(
"Demo world seeded: {Nurses} nurse(s), {Customers} customer(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.",
seededNurses, seededCustomers, rebuild.NursesProcessed, rebuild.RowsWritten);
}
private async Task<(long GroupId, IReadOnlyDictionary<string, long> ValueIdByCode)> EnsureShiftTypeGroupAsync(
CancellationToken cancellationToken)
{
var group = await db.Set<ServiceOptionGroup>()
.Include(g => g.Values)
.FirstOrDefaultAsync(
g => g.ServiceCategoryId == null && g.NameEn == DemoWorldDefinitions.ShiftGroupNameEn,
cancellationToken);
if (group is null)
{
group = new ServiceOptionGroup
{
ServiceCategoryId = null, // cross-category: applies to every category
NameFa = DemoWorldDefinitions.ShiftGroupNameFa,
NameEn = DemoWorldDefinitions.ShiftGroupNameEn,
IsRequired = true,
SortOrder = 1,
IsActive = true,
Values = DemoWorldDefinitions.ShiftValues
.Select((v, i) => new ServiceOptionValue
{
NameFa = v.NameFa,
NameEn = v.NameEn,
SortOrder = i + 1,
IsActive = true
})
.ToList()
};
db.Set<ServiceOptionGroup>().Add(group);
await db.SaveChangesAsync(cancellationToken);
}
var valueIdByCode = group.Values.ToDictionary(v => v.NameEn, v => v.Id);
return (group.Id, valueIdByCode);
}
/// <returns><c>true</c> if the persona was newly created; <c>false</c> if it already existed.</returns>
private async Task<bool> EnsureNurseAsync(
NursePersona persona,
long shiftGroupId,
IReadOnlyDictionary<string, long> shiftValueIdByCode,
CancellationToken cancellationToken)
{
if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
return false;
var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
persona.Gender, persona.NationalId, RoleNames.Nurse, cancellationToken);
var now = clock.UtcNow;
var profile = new NurseProfile
{
UserId = user.Id,
Bio = persona.Bio,
YearsOfExperience = persona.YearsOfExperience,
EducationLevel = "کارشناسی",
EducationField = "پرستاری",
SpecializationsJson = "[]"
};
profile.SetAcceptingBookings(true);
db.Set<NurseProfile>().Add(profile);
await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows
// Verification record: the single source of verification truth. A verified nurse is approved; the
// profile's guarded is_verified flag is flipped through its sanctioned write path (mirroring b6's
// finalize), which is what the search maintainer and trust badge read.
var verification = new NurseVerification
{
NurseId = profile.Id,
Status = persona.IsVerified ? VerificationStatus.Approved : VerificationStatus.Pending,
SubmittedAt = now,
ApprovedAt = persona.IsVerified ? now : null
};
db.Set<NurseVerification>().Add(verification);
if (persona.IsVerified)
profile.MarkVerified();
foreach (var credential in persona.Credentials)
db.Set<NurseCredential>().Add(new NurseCredential
{
NurseId = profile.Id,
CredentialType = credential.Type,
CredentialNumber = credential.Number, // encrypted at rest by the value converter
HolderNameSnapshot = $"{persona.Name} {persona.FamilyName}",
IssuingAuthority = credential.IssuingAuthority,
IssuedAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(-1),
ExpiresAt = DateOnly.FromDateTime(now.UtcDateTime).AddYears(2),
VerificationMethod = VerificationMethods.Manual
});
if (persona.Bank is { } bank)
{
var iban = bank.Iban;
db.Set<NurseBankAccount>().Add(new NurseBankAccount
{
NurseId = profile.Id,
BankName = bank.BankName,
AccountHolderName = bank.AccountHolderName, // encrypted at rest by the value converter
Iban = iban, // encrypted at rest by the value converter
IbanHash = encryptor.Hash(iban), // deterministic lookup/uniqueness column
IsPrimary = true,
IsVerified = bank.MatchedNationalId,
VerifiedAt = bank.MatchedNationalId ? now : null,
MatchedNationalId = bank.MatchedNationalId,
AccountHolderFromBank = bank.MatchedNationalId ? bank.AccountHolderName : null!,
OwnershipVendorRef = bank.MatchedNationalId ? $"demo-sheba-{profile.Id}" : null!
});
}
foreach (var variantDef in persona.Variants)
{
var shiftValueId = shiftValueIdByCode[variantDef.ShiftValueEn];
var optionPairs = new[] { (shiftGroupId, shiftValueId) };
db.Set<NurseServiceVariant>().Add(new NurseServiceVariant
{
NurseId = profile.Id,
ServiceCategoryId = variantDef.CategoryId,
Price = variantDef.Price,
PriceUnit = variantDef.PriceUnit,
SessionCount = variantDef.SessionCount,
DisplayName = variantDef.DisplayName,
OptionSetHash = OptionSetHash.Compute(optionPairs),
IsActive = true,
Options = new List<NurseServiceVariantOption>
{
new() { OptionGroupId = shiftGroupId, OptionValueId = shiftValueId }
}
});
}
foreach (var area in persona.Areas)
db.Set<NurseServiceArea>().Add(new NurseServiceArea
{
NurseId = profile.Id,
CityId = area.CityId,
DistrictId = area.DistrictId,
IsActive = true
});
await db.SaveChangesAsync(cancellationToken);
return true;
}
private async Task<bool> EnsureCustomerAsync(CustomerPersona persona, CancellationToken cancellationToken)
{
if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
return false;
var user = await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
persona.Gender, nationalId: null, RoleNames.Customer, cancellationToken);
var profile = new CustomerProfile
{
UserId = user.Id,
DefaultEmergencyContactName = persona.EmergencyContactName, // encrypted at rest
DefaultEmergencyContactPhone = persona.EmergencyContactPhone // encrypted at rest
};
db.Set<CustomerProfile>().Add(profile);
await db.SaveChangesAsync(cancellationToken); // assigns profile.Id used by the FK-only child rows
foreach (var patient in persona.Patients)
db.Set<Patient>().Add(new Patient
{
CustomerId = profile.Id,
DisplayName = patient.DisplayName,
FirstName = patient.FirstName,
LastName = patient.LastName,
Gender = patient.Gender,
BirthDate = patient.BirthDate,
BloodType = patient.BloodType,
InitialMedicalNotes = patient.InitialMedicalNotes, // encrypted at rest
IsActive = true
});
foreach (var address in persona.Addresses)
db.Set<CustomerAddress>().Add(new CustomerAddress
{
CustomerId = profile.Id,
CityId = DemoWorldDefinitions.TehranCityId,
DistrictId = address.DistrictId,
Title = address.Title,
AddressLine = address.AddressLine, // encrypted at rest
PostalCode = address.PostalCode, // encrypted at rest
Latitude = address.Latitude,
Longitude = address.Longitude,
IsPrimary = address.IsPrimary,
RecipientName = address.RecipientName, // encrypted at rest
RecipientPhone = address.RecipientPhone // encrypted at rest
});
await db.SaveChangesAsync(cancellationToken);
return true;
}
private async Task<User> CreateUserAsync(
string userName, string phone, string name, string familyName, string gender,
string? nationalId, string roleName, CancellationToken cancellationToken)
{
var user = new User
{
UserName = userName,
PhoneNumber = phone, // encrypted at rest; PhoneHash synced on SaveChanges
PhoneNumberConfirmed = true, // so the demo account logs in immediately via the OTP flow
PhoneVerifiedAt = clock.UtcNow,
Name = name,
FamilyName = familyName,
Gender = gender,
NationalId = nationalId, // encrypted at rest (null for the unverified/customer personas)
IsActive = true
};
var createResult = await userManager.CreateUser(user);
if (!createResult.Succeeded)
throw new InvalidOperationException(
$"Demo seed failed to create user '{userName}': {string.Join("; ", createResult.Errors.Select(e => e.Description))}");
var role = await db.Set<Role>().FirstOrDefaultAsync(r => r.Name == roleName, cancellationToken)
?? throw new InvalidOperationException($"Role '{roleName}' is not seeded — cannot assign it to the demo user.");
await userManager.AddUserToRoleAsync(user, role);
return user;
}
}
@@ -0,0 +1,90 @@
using System.Net;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Services.Seeding;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Api;
/// <summary>
/// Proves the Development demo seeder end-to-end over the real HTTP pipeline (refinement-phase-1): running it
/// populates a coherent marketplace, the verified nurses surface on the public search route, the unverified
/// one does not, the trust badge reflects verification, and a second run is a no-op (idempotent).
/// </summary>
public class DemoWorldSeederTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const long TehranCityId = 101;
private const long ElderlyCareCategoryId = 1; // offered only by the verified nurses
private const long InfantCareCategoryId = 3; // offered only by the unverified nurse
private async Task RunSeederAsync()
{
using var scope = factory.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<DemoWorldSeeder>();
await seeder.SeedAsync(CancellationToken.None);
}
[Fact]
public async Task Seed_Then_Search_VerifiedNurseSurfaces_UnverifiedDoesNot()
{
await RunSeederAsync();
var client = factory.CreateClient();
// Elderly care is offered only by the verified nurses → at least one searchable result.
var elderly = await client.GetAsync(
$"/api/v1/search/nurses?service_category_id={ElderlyCareCategoryId}&city_id={TehranCityId}");
Assert.Equal(HttpStatusCode.OK, elderly.StatusCode);
var elderlyData = await AuthTestClient.ReadDataAsync(elderly);
Assert.True(elderlyData.GetProperty("total").GetInt32() > 0);
// Infant care is offered only by the unverified nurse → is_searchable=0, so nothing surfaces.
var infant = await client.GetAsync(
$"/api/v1/search/nurses?service_category_id={InfantCareCategoryId}&city_id={TehranCityId}");
Assert.Equal(HttpStatusCode.OK, infant.StatusCode);
var infantData = await AuthTestClient.ReadDataAsync(infant);
Assert.Equal(0, infantData.GetProperty("total").GetInt32());
}
[Fact]
public async Task Seed_VerifiedNurse_HasVerifiedBadge_UnverifiedDoesNot()
{
await RunSeederAsync();
long verifiedNurseId;
long unverifiedNurseId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
verifiedNurseId = await db.Set<NurseProfile>().Where(p => p.IsVerified).Select(p => p.Id).FirstAsync();
unverifiedNurseId = await db.Set<NurseProfile>().Where(p => !p.IsVerified).Select(p => p.Id).FirstAsync();
}
var client = factory.CreateClient();
var verified = await client.GetAsync($"/api/v1/nurses/{verifiedNurseId}/trust_badge");
Assert.Equal(HttpStatusCode.OK, verified.StatusCode);
var verifiedBadge = await AuthTestClient.ReadDataAsync(verified);
Assert.True(verifiedBadge.GetProperty("isVerified").GetBoolean());
Assert.NotEmpty(verifiedBadge.GetProperty("credentialTypes").EnumerateArray());
var unverified = await client.GetAsync($"/api/v1/nurses/{unverifiedNurseId}/trust_badge");
Assert.Equal(HttpStatusCode.OK, unverified.StatusCode);
var unverifiedBadge = await AuthTestClient.ReadDataAsync(unverified);
Assert.False(unverifiedBadge.GetProperty("isVerified").GetBoolean());
}
[Fact]
public async Task Seed_IsIdempotent_SecondRunAddsNoDuplicates()
{
await RunSeederAsync();
await RunSeederAsync();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(DemoWorldDefinitions.Nurses.Length, await db.Set<NurseProfile>().CountAsync());
Assert.Equal(DemoWorldDefinitions.Customers.Length, await db.Set<CustomerProfile>().CountAsync());
}
}