manual improvement 1
This commit is contained in:
+131
-57
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user