manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
@@ -40,6 +40,20 @@ namespace Baya.Infrastructure.Persistence.Services.Seeding;
/// re-running on an already-seeded DB is a no-op. Callers must gate this on <c>IsDevelopment()</c> and run
/// it <b>after</b> <see cref="DemoWorldSeeder"/> and the payment-gateway seed.
/// </para>
/// <para>
/// Because several of those natural keys contain a <b>date</b>, the run is anchored to a stable epoch (the
/// first run's instant, read back from the demo payment transactions) rather than to wall-clock now — a
/// date-relative key silently stops matching the day after it was written, which turns "idempotent" into
/// "duplicates the whole world once per day". A wipe + reseed is what moves the demo world forward in time.
/// </para>
/// <para>
/// It is also <b>resumable</b>, which is a stronger property than idempotent and is what the booking
/// scenarios actually need: each scenario spans several aggregates that can only be written in sequence
/// (a generated id feeds the next FK), so it runs inside one transaction and every step re-checks its own
/// row rather than trusting a single scenario-level guard. An interrupted run therefore leaves nothing
/// half-written, and a database already damaged by one is repaired forward on the next boot instead of
/// throwing.
/// </para>
/// </summary>
internal sealed partial class DemoLifecycleSeeder(
ApplicationDbContext db,
@@ -69,7 +83,29 @@ internal sealed partial class DemoLifecycleSeeder(
return;
}
var now = clock.UtcNow.UtcDateTime;
// The whole seed is anchored to a STABLE epoch, not to wall-clock "now".
//
// Every scenario's idempotency key includes a date derived from this anchor (`Today(now, offset)`),
// so anchoring on the current time meant the key moved every calendar day: a run on the next day
// matched none of yesterday's rows, re-created all thirteen scenarios, and then collided on
// `GatewayReferenceCode` — which IS date-independent. That is how a supposed no-op re-run ended up
// throwing a duplicate-key error and leaving a scenario half-written for the next boot to trip over.
//
// Re-using the first run's instant makes every key reproduce exactly, so a re-run is a real no-op on
// any day. The anchor is read back from the demo transactions' audit stamp rather than stored
// separately — they are the one row set this seeder owns whose natural key (the reference prefix) is
// itself date-independent. A wipe + reseed is what moves the demo world forward in time.
// Ordered by Id, not CreatedAt: identity order IS insertion order (so it picks the same first row),
// it can't tie on a shared millisecond, and — the reason it matters here — the SQLite provider the
// API tests run on cannot translate a DateTimeOffset ORDER BY at all.
var seededAt = await db.Set<PaymentTransaction>()
.Where(t => t.GatewayReferenceCode != null
&& t.GatewayReferenceCode.StartsWith(DemoLifecycleDefinitions.GatewayRefPrefix))
.OrderBy(t => t.Id)
.Select(t => (DateTimeOffset?)t.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
var now = seededAt?.UtcDateTime ?? clock.UtcNow.UtcDateTime;
var packageVariantId = await EnsurePackageVariantAsync(world, cancellationToken);
var newRequests = await EnsureRequestScenariosAsync(world, now, cancellationToken);
@@ -353,78 +389,116 @@ internal sealed partial class DemoLifecycleSeeder(
.FirstAsync(v => v.Id == packageVariantId, cancellationToken);
var requestedDate = Today(now, scenario.ScheduledInDays);
var existing = await db.Set<BookingRequest>()
.Where(r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id
&& r.VariantId == variant.Id && r.RequestedDate == requestedDate)
.Select(r => r.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existing != 0)
{
var found = await db.Set<BookingEntity>().Include(b => b.Sessions)
.FirstAsync(b => b.BookingRequestId == existing, cancellationToken);
var foundTxn = await db.Set<PaymentTransaction>()
.FirstAsync(t => t.BookingId == found.Id && t.Status == PaymentTransactionStatus.Succeeded, cancellationToken);
result.ByKey[scenario.Key] = found;
result.TxnByKey[scenario.Key] = foundTxn;
continue;
}
var confirmedAt = now.AddDays(-scenario.ConfirmedDaysAgo);
var request = new BookingRequest
// One transaction per scenario. The saves below exist only to hand a generated id to the next
// step (request.Id → booking FK, session ids → EVV, txn.Id → ledger source ref), so they must
// never be independently observable: committing them separately is what let an interrupted run
// (a `dotnet watch` restart, a Ctrl-C, a transient DB error) leave a request with no booking, or
// a booking with no payment. Everything below is therefore all-or-nothing.
await using var scenarioTx = await db.Database.BeginTransactionAsync(cancellationToken);
// Resume is per STEP, not per scenario. The old probe keyed the whole scenario on the request —
// the row written FIRST — then assumed the rest existed, so a half-written scenario made the
// next boot throw ("Sequence contains no elements") instead of finishing the job. Each step now
// asks whether its own row is there, so an interrupted seed is completed rather than skipped
// (which would leave an unreachable world) or fatal.
var request = await db.Set<BookingRequest>()
.FirstOrDefaultAsync(
r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id
&& r.VariantId == variant.Id && r.RequestedDate == requestedDate,
cancellationToken);
var wroteAnything = false;
if (request is null)
{
CustomerId = customer.ProfileId,
NurseId = nurse.Profile.Id,
PatientId = customer.PatientIds[0],
VariantId = variant.Id,
CustomerAddressId = customer.PrimaryAddress.Id,
RequiredCaregiverGender = nurse.Gender,
RequestedDate = requestedDate,
RequestedTimeStart = new TimeOnly(9, 0),
RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "هماهنگی از طریق پیام انجام شد.",
NurseResponseDeadlineAt = confirmedAt
};
request.Accept(paymentDeadlineAt: confirmedAt);
db.Set<BookingRequest>().Add(request);
await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK
request = new BookingRequest
{
CustomerId = customer.ProfileId,
NurseId = nurse.Profile.Id,
PatientId = customer.PatientIds[0],
VariantId = variant.Id,
CustomerAddressId = customer.PrimaryAddress.Id,
RequiredCaregiverGender = nurse.Gender,
RequestedDate = requestedDate,
RequestedTimeStart = new TimeOnly(9, 0),
RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "هماهنگی از طریق پیام انجام شد.",
NurseResponseDeadlineAt = confirmedAt
};
request.Accept(paymentDeadlineAt: confirmedAt);
db.Set<BookingRequest>().Add(request);
await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK
wroteAnything = true;
}
var booking = BookingFactory.Create(
await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken),
rate: await GetFeeRateAsync(cancellationToken),
now: confirmedAt,
pspFeeAmount: null,
variantSerializer);
request.MarkConverted();
var booking = await db.Set<BookingEntity>().Include(b => b.Sessions)
.FirstOrDefaultAsync(b => b.BookingRequestId == request.Id, cancellationToken);
ApplyLifecycleState(booking, scenario, now);
db.Set<BookingEntity>().Add(booking);
await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs
if (booking is null)
{
booking = BookingFactory.Create(
await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken),
rate: await GetFeeRateAsync(cancellationToken),
now: confirmedAt,
pspFeeAmount: null,
variantSerializer);
SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now);
// Transition() fails fast on an illegal edge, and a resumed request may already be
// converted — re-driving it would turn a repairable state into a crash.
if (BookingRequestTransitions.CanTransition(request.Status, BookingRequestStatus.Converted))
request.MarkConverted();
if (scenario.WithCareInstructions)
db.Set<BookingCareInstruction>().Add(BuildCareInstructions(booking.Id));
ApplyLifecycleState(booking, scenario, now);
db.Set<BookingEntity>().Add(booking);
await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs
// The txn commits first so the ledger legs can reference its real id (append-only source refs).
var txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence);
db.Set<PaymentTransaction>().Add(txn);
await db.SaveChangesAsync(cancellationToken);
SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now);
// Card bookings post the balanced capture group now; the BNPL booking posts BnplSettle in the
if (scenario.WithCareInstructions)
db.Set<BookingCareInstruction>().Add(BuildCareInstructions(booking.Id));
await db.SaveChangesAsync(cancellationToken);
wroteAnything = true;
}
var txn = await db.Set<PaymentTransaction>()
.FirstOrDefaultAsync(
t => t.BookingId == booking.Id && t.Status == PaymentTransactionStatus.Succeeded,
cancellationToken);
if (txn is null)
{
// The txn commits first so the ledger legs can reference its real id (append-only source refs).
txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence);
db.Set<PaymentTransaction>().Add(txn);
await db.SaveChangesAsync(cancellationToken);
wroteAnything = true;
}
// Card bookings post the balanced capture group here; the BNPL booking posts BnplSettle in the
// money step (its group references the bnpl_transactions row, which doesn't exist yet).
if (scenario.Key != "bnpl_settled")
{
db.Set<LedgerEntry>().AddRange(LedgerPosting.CardCapture(
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr,
booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt));
await db.SaveChangesAsync(cancellationToken);
var capturePosted = await db.Set<LedgerEntry>().AnyAsync(
e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == txn.Id,
cancellationToken);
if (!capturePosted)
{
db.Set<LedgerEntry>().AddRange(LedgerPosting.CardCapture(
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr,
booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt));
await db.SaveChangesAsync(cancellationToken);
wroteAnything = true;
}
}
await scenarioTx.CommitAsync(cancellationToken);
result.ByKey[scenario.Key] = booking;
result.TxnByKey[scenario.Key] = txn;
result.CreatedCount++;
if (wroteAnything) result.CreatedCount++;
}
// One deliberately-failed extra payment attempt so the wallet's payment history has a failed row.
@@ -1,4 +1,10 @@
using System.Net;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Search;
using Mediator;
using Microsoft.Extensions.Logging;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
@@ -248,4 +254,67 @@ public class DemoLifecycleSeederTests(BayaApiFactory factory) : IClassFixture<Ba
Assert.Equal(ledger, await db.Set<LedgerEntry>().CountAsync());
}
}
/// <summary>A clock frozen at a chosen instant — enough to run the seeder "on a later day".</summary>
private sealed class FixedClock(DateTimeOffset now) : IDateTimeProvider
{
public DateTimeOffset UtcNow { get; } = now;
}
/// <summary>
/// Runs the lifecycle seeder alone against a chosen clock. Built by hand rather than resolved, so the
/// clock can move without swapping a registration on the fixture every other test shares.
/// </summary>
private async Task RunLifecycleSeedAsync(IServiceProvider services, IDateTimeProvider clock)
{
var seeder = new DemoLifecycleSeeder(
services.GetRequiredService<ApplicationDbContext>(),
services.GetRequiredService<IAppUserManager>(),
services.GetRequiredService<ISender>(),
services.GetRequiredService<IPlatformConfig>(),
services.GetRequiredService<IVariantSnapshotSerializer>(),
services.GetRequiredService<ISearchIndexMaintainer>(),
services.GetRequiredService<IFieldEncryptor>(),
clock,
services.GetRequiredService<ILogger<DemoLifecycleSeeder>>());
await seeder.SeedAsync(CancellationToken.None);
}
[Fact]
public async Task Seed_IsIdempotent_EvenWhenTheSecondRunHappensOnALaterDay()
{
// The regression this locks down. Several scenario keys contain a date derived from "now"
// (`Today(now, offset)`), so when the run was anchored to the wall clock the key silently moved
// at midnight: the next day's boot matched none of yesterday's rows, re-created all thirteen
// scenarios, and then collided on `GatewayReferenceCode` — which is date-independent — leaving a
// half-written scenario for the boot after that to crash on. Seeding twice within one day (the
// test above) can never surface that; only advancing the clock between runs does.
await RunSeedersAsync();
int requests, bookings, ledger, transactions;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
requests = await db.Set<BookingRequest>().CountAsync();
bookings = await db.Set<BookingEntity>().CountAsync();
ledger = await db.Set<LedgerEntry>().CountAsync();
transactions = await db.Set<PaymentTransaction>().CountAsync();
}
using (var scope = factory.Services.CreateScope())
{
var threeDaysOn = new FixedClock(DateTimeOffset.UtcNow.AddDays(3));
await RunLifecycleSeedAsync(scope.ServiceProvider, threeDaysOn);
}
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(requests, await db.Set<BookingRequest>().CountAsync());
Assert.Equal(bookings, await db.Set<BookingEntity>().CountAsync());
Assert.Equal(ledger, await db.Set<LedgerEntry>().CountAsync());
Assert.Equal(transactions, await db.Set<PaymentTransaction>().CountAsync());
}
}
}