535 lines
20 KiB
C#
535 lines
20 KiB
C#
using System.Text.Json;
|
|
using Confluent.Kafka;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StackExchange.Redis;
|
|
|
|
[Collection("Integration")]
|
|
public class BackgroundServiceTests : IAsyncLifetime
|
|
{
|
|
private readonly ApiFixture _fixture;
|
|
|
|
public BackgroundServiceTests(ApiFixture fixture) => _fixture = fixture;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await DbResetHelper.ResetAsync(db);
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
var server = redis.GetServer(redis.GetEndPoints().First());
|
|
await server.FlushDatabaseAsync(1);
|
|
}
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
// =======================================================================
|
|
// PoisonPillGuard — unit tests (no infrastructure needed)
|
|
// =======================================================================
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_PermanentError_SkipsImmediately()
|
|
{
|
|
var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance);
|
|
var result = BuildConsumeResult("test-topic", 0, 0, "bad payload");
|
|
|
|
var shouldSkip = guard.ShouldSkip(result, new JsonException("Invalid JSON"));
|
|
|
|
shouldSkip.Should().BeTrue("permanent errors (JsonException) must be skipped immediately");
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_FormatException_SkipsImmediately()
|
|
{
|
|
var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance);
|
|
var result = BuildConsumeResult("test-topic", 0, 0, "not-a-guid");
|
|
|
|
var shouldSkip = guard.ShouldSkip(result, new FormatException("Input string was not in a correct format"));
|
|
|
|
shouldSkip.Should().BeTrue("FormatException is a permanent error and must be skipped immediately");
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_WrappedPermanentError_SkipsImmediately()
|
|
{
|
|
var guard = new PoisonPillGuard("test-group", maxRetries: 5, NullLogger.Instance);
|
|
var result = BuildConsumeResult("test-topic", 0, 0, "bad");
|
|
|
|
var wrappedException = new InvalidOperationException(
|
|
"Processing failed", new JsonException("Invalid JSON"));
|
|
|
|
var shouldSkip = guard.ShouldSkip(result, wrappedException);
|
|
|
|
shouldSkip.Should().BeTrue(
|
|
"permanent errors wrapped in other exceptions must still be detected via root cause");
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_TransientError_RetriesUpToMax()
|
|
{
|
|
const int maxRetries = 3;
|
|
var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance);
|
|
var result = BuildConsumeResult("test-topic", 0, 42, "payload");
|
|
var transientError = new TimeoutException("Connection timed out");
|
|
|
|
for (var i = 1; i < maxRetries; i++)
|
|
{
|
|
var shouldSkip = guard.ShouldSkip(result, transientError);
|
|
shouldSkip.Should().BeFalse($"retry {i}/{maxRetries} should not skip");
|
|
}
|
|
|
|
var finalSkip = guard.ShouldSkip(result, transientError);
|
|
finalSkip.Should().BeTrue(
|
|
$"after {maxRetries} retries, the message must be skipped");
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_SuccessResetsRetryCount()
|
|
{
|
|
const int maxRetries = 3;
|
|
var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance);
|
|
var result = BuildConsumeResult("test-topic", 0, 42, "payload");
|
|
var transientError = new TimeoutException("Connection timed out");
|
|
|
|
guard.ShouldSkip(result, transientError);
|
|
guard.ShouldSkip(result, transientError);
|
|
|
|
guard.OnSuccess();
|
|
|
|
guard.ShouldSkip(result, transientError).Should().BeFalse(
|
|
"after OnSuccess, retry count resets and first failure should not skip");
|
|
}
|
|
|
|
[Fact]
|
|
public void PoisonPillGuard_DifferentOffset_ResetsRetryCount()
|
|
{
|
|
const int maxRetries = 3;
|
|
var guard = new PoisonPillGuard("test-group", maxRetries, NullLogger.Instance);
|
|
var transientError = new TimeoutException("Connection timed out");
|
|
|
|
var result1 = BuildConsumeResult("test-topic", 0, 42, "payload-a");
|
|
guard.ShouldSkip(result1, transientError);
|
|
guard.ShouldSkip(result1, transientError);
|
|
|
|
var result2 = BuildConsumeResult("test-topic", 0, 43, "payload-b");
|
|
guard.ShouldSkip(result2, transientError).Should().BeFalse(
|
|
"a new offset resets the retry counter");
|
|
}
|
|
|
|
// =======================================================================
|
|
// Outbox relay — retry count and dead-letter behavior
|
|
// =======================================================================
|
|
|
|
[Fact]
|
|
public async Task OutboxEvent_UnprocessedEvents_ArePickedUpByRelay()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var encounterId = Guid.NewGuid();
|
|
db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "observation.recorded",
|
|
Payload = JsonSerializer.Serialize(new { encounterId, code = "HEART_RATE", value = 80 }),
|
|
PartitionKey = encounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var pendingBefore = await db.OutboxEvents
|
|
.CountAsync(e => e.ProcessedAt == null && e.FailedAt == null);
|
|
|
|
pendingBefore.Should().BeGreaterThanOrEqualTo(1);
|
|
|
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(15);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(500);
|
|
var remaining = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.CountAsync(e => e.ProcessedAt == null && e.FailedAt == null);
|
|
if (remaining == 0) break;
|
|
}
|
|
|
|
var unprocessed = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.CountAsync(e => e.ProcessedAt == null && e.FailedAt == null);
|
|
|
|
unprocessed.Should().Be(0, "the outbox relay should process all pending events");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OutboxEvent_FailedEvents_HaveRetryCountAndError()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var failedEvent = new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "nonexistent.topic.that.will.fail",
|
|
Payload = "{}",
|
|
PartitionKey = "test",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
RetryCount = 9,
|
|
LastError = "Simulated prior failure"
|
|
};
|
|
db.OutboxEvents.Add(failedEvent);
|
|
await db.SaveChangesAsync();
|
|
|
|
failedEvent.RetryCount.Should().Be(9);
|
|
failedEvent.LastError.Should().NotBeNullOrEmpty();
|
|
failedEvent.ProcessedAt.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OutboxEvent_FailedAtSet_EventIsNotReprocessed()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var deadLetteredEvent = new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "test.topic",
|
|
Payload = "{}",
|
|
PartitionKey = "test",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
RetryCount = 10,
|
|
LastError = "Exceeded max retries",
|
|
FailedAt = DateTimeOffset.UtcNow.AddMinutes(-1)
|
|
};
|
|
db.OutboxEvents.Add(deadLetteredEvent);
|
|
await db.SaveChangesAsync();
|
|
|
|
await Task.Delay(3_000);
|
|
|
|
var evt = await db.OutboxEvents
|
|
.AsNoTracking()
|
|
.SingleAsync(e => e.Id == deadLetteredEvent.Id);
|
|
|
|
evt.ProcessedAt.Should().BeNull("dead-lettered events must not be reprocessed");
|
|
evt.FailedAt.Should().NotBeNull();
|
|
}
|
|
|
|
// =======================================================================
|
|
// SepsisBundleMonitor — concurrent scan safety
|
|
// =======================================================================
|
|
|
|
[Fact]
|
|
public async Task SepsisBundleMonitor_ConcurrentScans_NoDuplicateUpdates()
|
|
{
|
|
var (_, encounterId) = await SeedPatientEncounterAndBundleAsync();
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId);
|
|
bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var tasks = Enumerable.Range(0, 5).Select(_ =>
|
|
{
|
|
return Task.Run(async () =>
|
|
{
|
|
var monitor = new SepsisBundleMonitorService(
|
|
_fixture.Services,
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<SepsisBundleMonitorService>>());
|
|
await monitor.ScanOverdueBundlesAsync(CancellationToken.None);
|
|
});
|
|
});
|
|
|
|
await Task.WhenAll(tasks);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var bundles = await verifyDb.SepsisBundles
|
|
.AsNoTracking()
|
|
.Where(b => b.EncounterId == encounterId)
|
|
.ToListAsync();
|
|
|
|
bundles.Should().HaveCount(1);
|
|
bundles[0].ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.NonCompliant);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SepsisBundleMonitor_CompletedBundle_NotMarkedOverdue()
|
|
{
|
|
var (_, encounterId) = await SeedPatientEncounterAndBundleAsync();
|
|
|
|
using (var scope = _fixture.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId);
|
|
bundle.ComplianceStatus = SepsisBundleComplianceStatus.Compliant;
|
|
bundle.CompletedAt = DateTimeOffset.UtcNow;
|
|
bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var monitor = new SepsisBundleMonitorService(
|
|
_fixture.Services,
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<SepsisBundleMonitorService>>());
|
|
|
|
await monitor.ScanOverdueBundlesAsync(CancellationToken.None);
|
|
|
|
using var verifyScope = _fixture.Services.CreateScope();
|
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var bundle2 = await verifyDb.SepsisBundles
|
|
.AsNoTracking()
|
|
.SingleAsync(b => b.EncounterId == encounterId);
|
|
|
|
bundle2.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.Compliant,
|
|
"already-completed bundles must not be marked non-compliant by the monitor");
|
|
}
|
|
|
|
// =======================================================================
|
|
// EscalationWorker — only escalates open alerts
|
|
// =======================================================================
|
|
|
|
[Fact]
|
|
public async Task EscalationWorker_AcknowledgedAlert_NotEscalated()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var (patientId, encounterId) = await SeedPatientAndEncounterAsync();
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId,
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
AlertType = AlertType.SofaSepsis,
|
|
Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2",
|
|
Status = AlertStatus.Acknowledged,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var escalationService = new EscalationWorkerService(
|
|
_fixture.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<RabbitMqOptions>>(),
|
|
_fixture.Services.GetRequiredService<IServiceScopeFactory>(),
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<EscalationWorkerService>>());
|
|
|
|
var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId);
|
|
|
|
escalated.Should().BeFalse("acknowledged alerts must not be escalated");
|
|
|
|
var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId);
|
|
alert.Status.Should().Be(AlertStatus.Acknowledged);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EscalationWorker_ResolvedAlert_NotEscalated()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var (patientId, encounterId) = await SeedPatientAndEncounterAsync();
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId,
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
AlertType = AlertType.SofaSepsis,
|
|
Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2",
|
|
Status = AlertStatus.Resolved,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var escalationService = new EscalationWorkerService(
|
|
_fixture.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<RabbitMqOptions>>(),
|
|
_fixture.Services.GetRequiredService<IServiceScopeFactory>(),
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<EscalationWorkerService>>());
|
|
|
|
var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId);
|
|
|
|
escalated.Should().BeFalse("resolved alerts must not be escalated");
|
|
|
|
var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId);
|
|
alert.Status.Should().Be(AlertStatus.Resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EscalationWorker_OpenAlert_IsEscalated()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var (patientId, encounterId) = await SeedPatientAndEncounterAsync();
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId,
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
AlertType = AlertType.SofaSepsis,
|
|
Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2",
|
|
Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var escalationService = new EscalationWorkerService(
|
|
_fixture.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<RabbitMqOptions>>(),
|
|
_fixture.Services.GetRequiredService<IServiceScopeFactory>(),
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<EscalationWorkerService>>());
|
|
|
|
var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, alertId);
|
|
|
|
escalated.Should().BeTrue("open alerts must be escalated");
|
|
|
|
var alert = await db.ClinicalAlerts.AsNoTracking().SingleAsync(a => a.Id == alertId);
|
|
alert.Status.Should().Be(AlertStatus.Escalated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EscalationWorker_NonexistentAlert_ReturnsFalse()
|
|
{
|
|
var escalationService = new EscalationWorkerService(
|
|
_fixture.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<RabbitMqOptions>>(),
|
|
_fixture.Services.GetRequiredService<IServiceScopeFactory>(),
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<EscalationWorkerService>>());
|
|
|
|
var escalated = await InvokeUpdateAlertStatusEscalatedAsync(escalationService, Guid.NewGuid());
|
|
|
|
escalated.Should().BeFalse("nonexistent alerts must not cause errors");
|
|
}
|
|
|
|
// =======================================================================
|
|
// Cancellation — background services respect CancellationToken
|
|
// =======================================================================
|
|
|
|
[Fact]
|
|
public async Task SepsisBundleMonitor_CancellationRequested_StopsGracefully()
|
|
{
|
|
var monitor = new SepsisBundleMonitorService(
|
|
_fixture.Services,
|
|
_fixture.Services.GetRequiredService<ClinicalMetrics>(),
|
|
_fixture.Services.GetRequiredService<ILogger<SepsisBundleMonitorService>>());
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var act = () => monitor.ScanOverdueBundlesAsync(cts.Token);
|
|
|
|
await act.Should().ThrowAsync<OperationCanceledException>();
|
|
}
|
|
|
|
// =======================================================================
|
|
// Helpers
|
|
// =======================================================================
|
|
|
|
private static ConsumeResult<string, string> BuildConsumeResult(
|
|
string topic, int partition, long offset, string value)
|
|
{
|
|
return new ConsumeResult<string, string>
|
|
{
|
|
Topic = topic,
|
|
Partition = new Partition(partition),
|
|
Offset = new Offset(offset),
|
|
Message = new Message<string, string>
|
|
{
|
|
Key = "test-key",
|
|
Value = value
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task<(Guid PatientId, Guid EncounterId)> SeedPatientAndEncounterAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Mrn = $"MRN-BG-{Guid.NewGuid():N}"[..20],
|
|
FirstName = "Background",
|
|
LastName = "Test",
|
|
DateOfBirth = new DateOnly(1970, 1, 1),
|
|
Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PatientId = patient.Id,
|
|
EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active,
|
|
Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Background",
|
|
AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
await db.SaveChangesAsync();
|
|
|
|
return (patient.Id, encounter.Id);
|
|
}
|
|
|
|
private async Task<(Guid PatientId, Guid EncounterId)> SeedPatientEncounterAndBundleAsync()
|
|
{
|
|
var (patientId, encounterId) = await SeedPatientAndEncounterAsync();
|
|
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId,
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
AlertType = AlertType.SofaSepsis,
|
|
Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2",
|
|
Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
await handler.OnSepsisAlertCreatedAsync(
|
|
encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None);
|
|
|
|
return (patientId, encounterId);
|
|
}
|
|
|
|
private static async Task<bool> InvokeUpdateAlertStatusEscalatedAsync(
|
|
EscalationWorkerService service, Guid alertId)
|
|
{
|
|
var method = typeof(EscalationWorkerService).GetMethod(
|
|
"UpdateAlertStatusEscalatedAsync",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
|
|
var task = (Task<bool>)method!.Invoke(service, new object[] { alertId, CancellationToken.None })!;
|
|
return await task;
|
|
}
|
|
}
|