refinement phase 7
This commit is contained in:
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
|
||||
[HttpPost("batches")]
|
||||
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
|
||||
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
// SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
|
||||
// record a batch without an authenticated admin initiator (refinement-phase-7).
|
||||
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
|
||||
|
||||
[HttpPost("batches/{id}/process")]
|
||||
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
|
||||
|
||||
@@ -110,22 +110,43 @@ builder.Services.ConfigureGrpcPluginServices();
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
||||
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
|
||||
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
|
||||
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
|
||||
// permanent DDL rights.
|
||||
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
|
||||
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
|
||||
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
|
||||
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
|
||||
// Production/Staging. Both are idempotent.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
|
||||
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
|
||||
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
|
||||
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
|
||||
await app.ApplyMigrationsAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
await app.SeedPaymentGatewaysAsync();
|
||||
await app.SeedDemoWorldAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
|
||||
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
|
||||
await app.EnsureSchemaUpToDateAsync();
|
||||
await app.SeedDefaultUsersAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
||||
+9
-2
@@ -33,7 +33,14 @@ internal sealed class GeneratePayoutBatchCommandHandler(
|
||||
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
|
||||
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
// A scheduled (system-initiated) run has no human initiator; the HTTP path still requires an authenticated
|
||||
// admin and records their id. SystemInitiated can only be set in-process by the scheduler.
|
||||
int? initiatedByAdminId;
|
||||
if (request.SystemInitiated)
|
||||
initiatedByAdminId = null;
|
||||
else if (currentUser.UserId is { } adminId)
|
||||
initiatedByAdminId = adminId;
|
||||
else
|
||||
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
@@ -57,7 +64,7 @@ internal sealed class GeneratePayoutBatchCommandHandler(
|
||||
PeriodStart = request.PeriodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
ProcessingDate = processingDate,
|
||||
InitiatedByAdminId = adminId
|
||||
InitiatedByAdminId = initiatedByAdminId
|
||||
};
|
||||
|
||||
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
|
||||
|
||||
+10
-1
@@ -9,4 +9,13 @@ namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
|
||||
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
|
||||
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
|
||||
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
|
||||
: IRequest<OperationResult<GeneratePayoutBatchResult>>
|
||||
{
|
||||
/// <summary>
|
||||
/// True only when the in-process scheduler runs the weekly generation unattended (refinement-phase-7): the
|
||||
/// batch is recorded with a null initiator instead of requiring an authenticated admin. The HTTP path always
|
||||
/// leaves this <c>false</c> — <c>AdminPayoutsController</c> neutralizes any request-supplied value — so it can
|
||||
/// never be set by an API caller.
|
||||
/// </summary>
|
||||
public bool SystemInitiated { get; init; }
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public record PayoutBatchDto(
|
||||
string TotalAmount,
|
||||
int PayoutCount,
|
||||
string Status,
|
||||
int InitiatedByAdminId,
|
||||
int? InitiatedByAdminId,
|
||||
DateTime? ProcessedAt,
|
||||
string? FailureNotes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -35,8 +35,9 @@ public class NursePayoutBatch : BaseEntity<long>, IAuditable
|
||||
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
|
||||
public string Status { get; private set; } = PayoutBatchStatus.Draft;
|
||||
|
||||
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
|
||||
public int InitiatedByAdminId { get; set; }
|
||||
/// <summary>The admin who initiated the run (FK <c>users</c>), or <c>null</c> for a system-initiated
|
||||
/// (scheduled/unattended) batch — the weekly cron has no human initiator (refinement-phase-7).</summary>
|
||||
public int? InitiatedByAdminId { get; set; }
|
||||
|
||||
public DateTime? ProcessedAt { get; private set; }
|
||||
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePay
|
||||
|
||||
builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired();
|
||||
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
|
||||
// Nullable: a system-initiated (scheduled) batch has no admin initiator (refinement-phase-7).
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired(false);
|
||||
|
||||
builder.HasQueryFilter(b => b.DeletedAt == null);
|
||||
}
|
||||
|
||||
+6046
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefinementPhase7SystemPayoutBatch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
column: "InitiatedByAdminId",
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
|
||||
schema: "payouts",
|
||||
table: "NursePayoutBatches",
|
||||
column: "InitiatedByAdminId",
|
||||
principalSchema: "usr",
|
||||
principalTable: "Users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -3638,7 +3638,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<int>("InitiatedByAdminId")
|
||||
b.Property<int?>("InitiatedByAdminId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
@@ -5610,9 +5610,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.User.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InitiatedByAdminId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
.HasForeignKey("InitiatedByAdminId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
|
||||
|
||||
+31
-7
@@ -13,11 +13,12 @@ using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||
using Baya.Infrastructure.Persistence.Services.Audit;
|
||||
using Baya.Infrastructure.Persistence.Services.Booking;
|
||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
using Baya.Infrastructure.Persistence.Services.Payments;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
using Baya.Infrastructure.Persistence.Services.Search;
|
||||
using Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
|
||||
@@ -60,12 +61,16 @@ public static class ServiceCollectionExtensions
|
||||
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
|
||||
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
|
||||
|
||||
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
|
||||
services.AddHostedService<NotificationRetentionHostedService>();
|
||||
|
||||
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
|
||||
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
|
||||
services.AddHostedService<BookingRequestExpiryHostedService>();
|
||||
// Unattended operation (refinement-phase-7). One in-process scheduler drives every IRecurringJob on its
|
||||
// own cadence — the two re-homed sweeps plus the previously admin-manual crons, each reading its seeded
|
||||
// cadence key. No new infra: SQL Server stays the only external dependency (Hangfire/Quartz + Redis are the
|
||||
// documented scale-out gate for >1 instance). Phase 8 adds the Moadian reconciliation job the same way.
|
||||
services.AddSingleton<IRecurringJob, BookingRequestExpiryJob>();
|
||||
services.AddSingleton<IRecurringJob, NotificationRetentionJob>();
|
||||
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
|
||||
services.AddSingleton<IRecurringJob, NoShowSweepJob>();
|
||||
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
|
||||
services.AddHostedService<RecurringJobSchedulerHostedService>();
|
||||
|
||||
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
|
||||
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
|
||||
@@ -97,6 +102,25 @@ public static class ServiceCollectionExtensions
|
||||
await context.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boot-time schema <b>check</b> (refinement-phase-7) for deployed environments: applying migrations is a
|
||||
/// separate deploy step (the <c>migrate</c> one-shot / a CI <c>dotnet ef database update</c>), so concurrent
|
||||
/// multi-instance start-ups never race on DDL and the app login needs no permanent DDL rights. If any
|
||||
/// migration is pending, fail fast with a clear message rather than starting against a stale schema.
|
||||
/// </summary>
|
||||
public static async Task EnsureSchemaUpToDateAsync(this WebApplication app)
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetService<ApplicationDbContext>()
|
||||
?? throw new Exception("Database Context Not Found");
|
||||
|
||||
var pending = (await context.Database.GetPendingMigrationsAsync()).ToList();
|
||||
if (pending.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Database schema is not up to date — {pending.Count} migration(s) pending: {string.Join(", ", pending)}. " +
|
||||
"Run the deploy-time migration step (`dotnet run -- migrate`, or `dotnet ef database update` in CI) before starting the API.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
|
||||
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
|
||||
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
|
||||
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
|
||||
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
|
||||
/// interval config key for booking expiry, so it is a documented constant.
|
||||
/// </summary>
|
||||
internal sealed class BookingRequestExpiryHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SweepSafely(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await SweepSafely(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task SweepSafely(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
|
||||
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
|
||||
logger.LogInformation(
|
||||
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
|
||||
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Booking-request expiry sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
|
||||
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
|
||||
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
|
||||
/// </summary>
|
||||
internal sealed class NotificationRetentionHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
|
||||
{
|
||||
private const int RetentionDays = 90;
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Run once at startup, then on the interval.
|
||||
await PurgeSafely(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await PurgeSafely(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task PurgeSafely(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
|
||||
if (removed > 0)
|
||||
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Notification retention purge failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// One recurring background job driven by <see cref="RecurringJobSchedulerHostedService"/>. Each
|
||||
/// implementation re-homes a previously admin-manual (or hardcoded-interval) sweep behind the same seam so
|
||||
/// the platform runs itself: the scheduler owns the loop, the per-tick DI scope, the distributed lock, and
|
||||
/// the error handling, while the job only says <em>how often</em> it runs and <em>what</em> one idempotent
|
||||
/// run does. Every run must be idempotent — a scheduler retry (or a second instance once the lock is
|
||||
/// Redis-backed) must never double-pay or double-post; the DB uniques/state-machines are the backstop.
|
||||
/// </summary>
|
||||
internal interface IRecurringJob
|
||||
{
|
||||
/// <summary>Stable identifier — used for the distributed-lock key (<c>scheduler:{Name}</c>) and log scope.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves this job's run interval, read fresh each tick (usually from a <c>platform_configs</c> cadence
|
||||
/// key via <see cref="Baya.Application.Contracts.Configuration.IPlatformConfig"/>) so an admin cadence
|
||||
/// change takes effect without a restart. <paramref name="services"/> is the per-tick scoped provider.
|
||||
/// </summary>
|
||||
ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Executes one idempotent run within the provided per-tick DI scope.</summary>
|
||||
ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-expires stale <c>booking_requests</c> (re-homed from the b8 <c>BookingRequestExpiryHostedService</c>).
|
||||
/// Each run sends <see cref="ExpireBookingRequestsCommand"/>, which transitions stale rows
|
||||
/// (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded, idempotent batches. The interval
|
||||
/// is a short constant because the payment window is only 30 minutes; there is no cadence config key for it.
|
||||
/// Also reachable via the admin manual trigger.
|
||||
/// </summary>
|
||||
internal sealed class BookingRequestExpiryJob(ILogger<BookingRequestExpiryJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "booking_request_expiry";
|
||||
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromMinutes(1));
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
|
||||
logger.LogInformation(
|
||||
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
|
||||
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans for lapsed time-limited verification steps (criminal-record especially), reverting each to expired,
|
||||
/// raising a renewal alert/notification, and re-gating bookability. Previously admin-manual
|
||||
/// (<c>admin_verifications/scan_expiring</c>) — now scheduled on the <c>verification_expiry_scan_cadence_hours</c>
|
||||
/// cadence; the admin trigger remains an override. Sends <see cref="ScanExpiringCredentialsCommand"/> (idempotent).
|
||||
/// </summary>
|
||||
internal sealed class CredentialExpiryScanJob(ILogger<CredentialExpiryScanJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "verification_expiry_scan";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("verification_expiry_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ScanExpiringCredentialsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } scan && scan.RevertedNurses > 0)
|
||||
logger.LogInformation(
|
||||
"Credential-expiry scan reverted {Nurses} nurse(s) across {Steps} expired step(s)",
|
||||
scan.RevertedNurses, scan.ScannedSteps);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Flags booking sessions whose scheduled start passed with no EVV check-in as no-shows. Previously admin-manual
|
||||
/// (<c>admin_evv/detect_no_shows</c>) — now scheduled on the <c>no_show_scan_cadence_hours</c> cadence; the admin
|
||||
/// trigger remains an override. Sends <see cref="DetectNoShowSessionsCommand"/> (idempotent — an already-flagged
|
||||
/// session is not re-flagged).
|
||||
/// </summary>
|
||||
internal sealed class NoShowSweepJob(ILogger<NoShowSweepJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "no_show_sweep";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("no_show_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { Missed: > 0 } sweep)
|
||||
logger.LogInformation("No-show sweep flagged {Missed} missed session(s)", sweep.Missed);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Hard-deletes read notifications older than the retention window (re-homed from the b1
|
||||
/// <c>NotificationRetentionHostedService</c>). Unread notifications are never deleted. Retention window and
|
||||
/// cadence are documented constants (no config key).
|
||||
/// </summary>
|
||||
internal sealed class NotificationRetentionJob(ILogger<NotificationRetentionJob> logger) : IRecurringJob
|
||||
{
|
||||
private const int RetentionDays = 90;
|
||||
|
||||
public string Name => "notification_retention";
|
||||
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromHours(24));
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var notifications = services.GetRequiredService<INotificationService>();
|
||||
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
|
||||
|
||||
if (removed > 0)
|
||||
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the weekly nurse-payout batch (previously admin-manual — <c>nurse_payout_interval_days</c> was seeded
|
||||
/// but nothing read it, so nurses were paid only when an operator clicked). Scheduled on that cadence, it opens a
|
||||
/// <c>draft</c> batch over the trailing window; the admin generate trigger remains an override.
|
||||
///
|
||||
/// <para><b>Generation only — never money movement.</b> Per the phase's "keep processing human-approved until trust
|
||||
/// is earned", this schedules <em>batch generation</em>; the irreversible <c>process</c> step stays an explicit
|
||||
/// admin action. The batch is <b>system-initiated</b> (<see cref="GeneratePayoutBatchCommand.SystemInitiated"/> →
|
||||
/// null initiator). A quiet week (no eligible bookings) returns a benign failure, logged at debug — not an error.
|
||||
/// Re-running over an overlapping window is safe: the <c>nurse_payout_booking_links.booking_id</c> UNIQUE prevents
|
||||
/// re-selecting an already-paid booking.</para>
|
||||
/// </summary>
|
||||
internal sealed class WeeklyPayoutGenerationJob(ILogger<WeeklyPayoutGenerationJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "weekly_payout_generation";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var days = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
|
||||
return TimeSpan.FromDays(days);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = services.GetRequiredService<IDateTimeProvider>();
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
|
||||
var intervalDays = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
|
||||
var periodEnd = DateOnly.FromDateTime(clock.UtcNow.UtcDateTime);
|
||||
var periodStart = periodEnd.AddDays(-Math.Max(intervalDays, 1));
|
||||
|
||||
var result = await sender.Send(
|
||||
new GeneratePayoutBatchCommand(periodStart, periodEnd) { SystemInitiated = true }, cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } generated)
|
||||
logger.LogInformation(
|
||||
"Weekly payout generation opened a draft batch of {Count} payout(s) totalling {Total} IRR (awaiting admin process)",
|
||||
generated.Batch.PayoutCount, generated.Batch.TotalAmount);
|
||||
else
|
||||
// Expected on a quiet week (no eligible bookings) — not an operational error.
|
||||
logger.LogDebug("Weekly payout generation produced no batch this run (no payout-eligible bookings).");
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The single in-process job scheduler (refinement-phase-7): it drives every registered <see cref="IRecurringJob"/>
|
||||
/// on its own cadence, replacing the two hand-written <c>PeriodicTimer</c> hosted services and giving the four
|
||||
/// previously admin-manual sweeps (credential-expiry scan, EVV no-show sweep, weekly payout-batch generation,
|
||||
/// and — Phase 8 — the Moadian reconciliation poll) a schedule. It intentionally uses <b>no new infrastructure</b>:
|
||||
/// SQL Server stays the only external dependency, so a single-instance MVP needs neither Hangfire/Quartz nor Redis.
|
||||
///
|
||||
/// <para><b>Multi-instance readiness.</b> Each tick runs under <see cref="IDistributedLock"/>
|
||||
/// (<c>scheduler:{Name}</c>). Today that lock is in-process (a no-op across instances); the moment a second API
|
||||
/// instance runs it becomes the scale-out gate — swapping the lock seam to Redis serializes ticks across nodes.
|
||||
/// Because every job is idempotent and the DB uniques/state-machines are authoritative, even a double-run is safe.</para>
|
||||
///
|
||||
/// <para><b>Not started under the "Testing" environment</b> so integration tests (WebApplicationFactory over
|
||||
/// in-memory SQLite) stay deterministic — no background sweep mutates rows mid-assertion. Each job's underlying
|
||||
/// command/service is unit-tested directly instead.</para>
|
||||
/// </summary>
|
||||
internal sealed class RecurringJobSchedulerHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEnumerable<IRecurringJob> jobs,
|
||||
IDistributedLock distributedLock,
|
||||
IHostEnvironment environment,
|
||||
ILogger<RecurringJobSchedulerHostedService> logger) : BackgroundService
|
||||
{
|
||||
// Floor so a mis-set cadence key (0 or negative) can never turn a loop into a hot spin.
|
||||
private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(10);
|
||||
// Used when a job's interval resolution throws (e.g. the config store is briefly unreachable).
|
||||
private static readonly TimeSpan FallbackInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (environment.IsEnvironment("Testing"))
|
||||
return;
|
||||
|
||||
var jobList = jobs.ToArray();
|
||||
logger.LogInformation("Recurring job scheduler starting with {Count} job(s): {Jobs}",
|
||||
jobList.Length, string.Join(", ", jobList.Select(j => j.Name)));
|
||||
|
||||
// One independent loop per job so their cadences don't couple. A crash in one loop never stops the others.
|
||||
await Task.WhenAll(jobList.Select(job => RunJobLoopAsync(job, stoppingToken)));
|
||||
}
|
||||
|
||||
private async Task RunJobLoopAsync(IRecurringJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
// Run once at startup, then on the job's own (re-read) cadence.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var interval = await TickAndResolveIntervalAsync(job, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // Host is shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeSpan> TickAndResolveIntervalAsync(IRecurringJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var services = scope.ServiceProvider;
|
||||
|
||||
await RunTickAsync(job, services, stoppingToken);
|
||||
return await ResolveIntervalAsync(job, services, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task RunTickAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var handle = await distributedLock.AcquireAsync($"scheduler:{job.Name}", stoppingToken);
|
||||
await job.RunAsync(services, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failing tick must never kill the loop — the next tick retries on schedule.
|
||||
logger.LogError(ex, "Recurring job {Job} failed", job.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeSpan> ResolveIntervalAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var interval = await job.GetIntervalAsync(services, stoppingToken);
|
||||
return interval < MinInterval ? MinInterval : interval;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Recurring job {Job} interval resolution failed; using fallback {Fallback}", job.Name, FallbackInterval);
|
||||
return FallbackInterval;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestration tests for <c>RecurringJobSchedulerHostedService</c> (refinement-phase-7): it runs each job at
|
||||
/// startup under the distributed lock, keeps sibling loops alive when one job throws, and stays dormant under the
|
||||
/// Testing environment so integration tests are deterministic.
|
||||
/// </summary>
|
||||
public sealed class RecurringJobSchedulerTests
|
||||
{
|
||||
private sealed class FakeEnvironment(string environmentName) : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = environmentName;
|
||||
public string ApplicationName { get; set; } = "Baya.Test";
|
||||
public string ContentRootPath { get; set; } = ".";
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
|
||||
private sealed class CountingJob(string name, bool throws = false) : IRecurringJob
|
||||
{
|
||||
private int _runCount;
|
||||
public int RunCount => Volatile.Read(ref _runCount);
|
||||
public string Name => name;
|
||||
|
||||
// Long interval: run once at startup, then idle for the whole test window.
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromHours(1));
|
||||
|
||||
public ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _runCount);
|
||||
if (throws)
|
||||
throw new InvalidOperationException("boom");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static (IServiceScopeFactory scopeFactory, IDistributedLock @lock) Fakes(out IDistributedLock recordingLock)
|
||||
{
|
||||
var scopeFactory = new ServiceCollection().BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
recordingLock = Substitute.For<IDistributedLock>();
|
||||
recordingLock.AcquireAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<IAsyncDisposable>(Substitute.For<IAsyncDisposable>()));
|
||||
return (scopeFactory, recordingLock);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunsJobAtStartup_UnderPerJobLock()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out var recordingLock);
|
||||
var job = new CountingJob("fake_job");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [job], @lock, new FakeEnvironment("Development"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await WaitUntilAsync(() => job.RunCount >= 1, TimeSpan.FromSeconds(3));
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.True(job.RunCount >= 1);
|
||||
await recordingLock.Received().AcquireAsync("scheduler:fake_job", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneJobThrowing_DoesNotStopSiblingJobs()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out _);
|
||||
var throwing = new CountingJob("throwing", throws: true);
|
||||
var healthy = new CountingJob("healthy");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [throwing, healthy], @lock, new FakeEnvironment("Development"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await WaitUntilAsync(() => healthy.RunCount >= 1, TimeSpan.FromSeconds(3));
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.True(throwing.RunCount >= 1);
|
||||
Assert.True(healthy.RunCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestingEnvironment_KeepsSchedulerDormant()
|
||||
{
|
||||
var (scopeFactory, @lock) = Fakes(out _);
|
||||
var job = new CountingJob("fake_job");
|
||||
var scheduler = new RecurringJobSchedulerHostedService(
|
||||
scopeFactory, [job], @lock, new FakeEnvironment("Testing"),
|
||||
NullLogger<RecurringJobSchedulerHostedService>.Instance);
|
||||
|
||||
await scheduler.StartAsync(default);
|
||||
await Task.Delay(200);
|
||||
await scheduler.StopAsync(default);
|
||||
|
||||
Assert.Equal(0, job.RunCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Payouts;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the recurring jobs (refinement-phase-7): each reads its seeded cadence key and dispatches the
|
||||
/// same idempotent command the admin manual trigger sends. No timers involved — pure job behaviour.
|
||||
/// </summary>
|
||||
public sealed class RecurringJobTests
|
||||
{
|
||||
private static IServiceProvider ProviderWith(params (Type Type, object Impl)[] services)
|
||||
{
|
||||
var provider = Substitute.For<IServiceProvider>();
|
||||
foreach (var (type, impl) in services)
|
||||
provider.GetService(type).Returns(impl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CredentialExpiryScan_ReadsCadenceKey_AndSendsScanCommand()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("verification_expiry_scan_cadence_hours", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(6));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(0, 0)));
|
||||
|
||||
var job = new CredentialExpiryScanJob(NullLogger<CredentialExpiryScanJob>.Instance);
|
||||
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(6), interval);
|
||||
await sender.Received(1).Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoShowSweep_ReadsCadenceKey_AndSendsDetectCommand()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("no_show_scan_cadence_hours", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(1));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0)));
|
||||
|
||||
var job = new NoShowSweepJob(NullLogger<NoShowSweepJob>.Instance);
|
||||
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(1), interval);
|
||||
await sender.Received(1).Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WeeklyPayoutGeneration_ReadsInterval_AndSendsSystemInitiatedBatchOverTrailingWindow()
|
||||
{
|
||||
var config = Substitute.For<IPlatformConfig>();
|
||||
config.GetConfig<int>("nurse_payout_interval_days", Arg.Any<CancellationToken>())
|
||||
.Returns(new ValueTask<int>(7));
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 13, 9, 0, 0, TimeSpan.Zero));
|
||||
var sender = Substitute.For<ISender>();
|
||||
sender.Send(Arg.Any<GeneratePayoutBatchCommand>(), Arg.Any<CancellationToken>())
|
||||
.Returns(OperationResult<GeneratePayoutBatchResult>.SuccessResult(
|
||||
new GeneratePayoutBatchResult(
|
||||
new PayoutBatchDto(1, new DateOnly(2026, 7, 6), new DateOnly(2026, 7, 13),
|
||||
new DateOnly(2026, 7, 13), "0", 0, "draft", null, null, null, DateTimeOffset.UtcNow),
|
||||
[], [])));
|
||||
|
||||
var job = new WeeklyPayoutGenerationJob(NullLogger<WeeklyPayoutGenerationJob>.Instance);
|
||||
var provider = ProviderWith(
|
||||
(typeof(IPlatformConfig), config), (typeof(IDateTimeProvider), clock), (typeof(ISender), sender));
|
||||
|
||||
var interval = await job.GetIntervalAsync(provider, default);
|
||||
await job.RunAsync(provider, default);
|
||||
|
||||
Assert.Equal(TimeSpan.FromDays(7), interval);
|
||||
await sender.Received(1).Send(
|
||||
Arg.Is<GeneratePayoutBatchCommand>(c =>
|
||||
c.SystemInitiated
|
||||
&& c.PeriodEnd == new DateOnly(2026, 7, 13)
|
||||
&& c.PeriodStart == new DateOnly(2026, 7, 6)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user