Clinical Sync Batch Engine: first commit
This commit is contained in:
@@ -90,8 +90,8 @@ public class RbacTests : IAsyncLifetime
|
||||
audit.GetProperty("data").GetProperty("totalCount").GetInt32().Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
|
||||
[Fact(Skip = "Not yet implemented")]
|
||||
public Task AlertAcknowledge_UsesAuthenticatedUser_NotBodyClinicianId()
|
||||
{
|
||||
_client.ClearAuth();
|
||||
var nurseId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
@@ -102,5 +102,6 @@ public class RbacTests : IAsyncLifetime
|
||||
|
||||
// Assert alert.AcknowledgedBy == "Test NURSE" (from TestingAuthHandler display_name)
|
||||
// Assert clinical_audit_logs row with action ALERT_ACKNOWLEDGED and userId == nurseId
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
using VigilCare.ClinicalContracts.Sync;
|
||||
|
||||
[Collection("Integration")]
|
||||
public class ClinicalSyncBatchTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _gatewayId;
|
||||
private Guid _siteId;
|
||||
private Guid _encounterId;
|
||||
|
||||
public ClinicalSyncBatchTests(ApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await GatewayRegistrySeeder.SeedAsync(db);
|
||||
await DataSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>());
|
||||
_gatewayId = GatewayRegistrySeeder.DemoGatewayId;
|
||||
_siteId = GatewayRegistrySeeder.DemoSiteId;
|
||||
_encounterId = await db.Encounters.Where(e => e.Status == EncounterStatus.Active)
|
||||
.Select(e => e.Id).FirstAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private ClinicalSyncBatchRequest BuildBatch(int obsCount = 1)
|
||||
{
|
||||
var batchRef = Guid.NewGuid();
|
||||
var observations = Enumerable.Range(0, obsCount).Select(i => new SyncedObservation(
|
||||
Guid.NewGuid(), $"sync-key-{batchRef}-{i}", _encounterId,
|
||||
"HEART_RATE", 80m + i, "bpm", "DEVICE", DateTimeOffset.UtcNow.AddMinutes(-i)
|
||||
)).ToList();
|
||||
|
||||
return new ClinicalSyncBatchRequest(
|
||||
batchRef, _gatewayId, _siteId, DateTimeOffset.UtcNow,
|
||||
observations, [], [], []);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatch_ReturnsReceived()
|
||||
{
|
||||
_client.WithGatewayApiKey(_gatewayId);
|
||||
var resp = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch());
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.OutboxEvents.CountAsync(e => e.Topic == "clinical.sync.batch_received"))
|
||||
.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateBatchReference_ReturnsExisting()
|
||||
{
|
||||
_client.WithGatewayApiKey(_gatewayId);
|
||||
var batch = BuildBatch();
|
||||
var first = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>();
|
||||
|
||||
var second = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
|
||||
var secondBody = await second.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>();
|
||||
|
||||
secondBody!.Data!.BatchId.Should().Be(firstBody!.Data!.BatchId);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.ClinicalSyncBatches.CountAsync(b => b.BatchReference == batch.BatchReference))
|
||||
.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_AppliesObservations()
|
||||
{
|
||||
_client.WithGatewayApiKey(_gatewayId);
|
||||
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch(3));
|
||||
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>();
|
||||
await processor.ProcessBatchAsync(batchId, CancellationToken.None);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
(await db.Observations.CountAsync()).Should().BeGreaterThanOrEqualTo(3);
|
||||
(await db.OutboxEvents.CountAsync(e => e.Topic == "observation.recorded")).Should().BeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_ConflictOnAckBeforeAlert()
|
||||
{
|
||||
var batchRef = Guid.NewGuid();
|
||||
var clientAlertId = Guid.NewGuid();
|
||||
var batch = new ClinicalSyncBatchRequest(
|
||||
batchRef, _gatewayId, _siteId, DateTimeOffset.UtcNow,
|
||||
[],
|
||||
[],
|
||||
[new SyncedAlertAcknowledgment(Guid.NewGuid(), clientAlertId, "RN-Smith",
|
||||
DateTimeOffset.UtcNow, null)],
|
||||
[]);
|
||||
|
||||
_client.WithGatewayApiKey(_gatewayId);
|
||||
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch);
|
||||
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>()
|
||||
.ProcessBatchAsync(batchId, CancellationToken.None);
|
||||
|
||||
var status = await _client.GetAsync($"/api/v1/sync/batches/{batchId}");
|
||||
var body = await status.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchStatusResponse>>();
|
||||
body!.Data!.Status.Should().Be("CONFLICT");
|
||||
body.Data.Conflicts.Should().ContainSingle(c => c.ConflictReason == "ALERT_NOT_YET_SYNCED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Processor_UpdatesGatewayLastSyncAt()
|
||||
{
|
||||
_client.WithGatewayApiKey(_gatewayId);
|
||||
var upload = await _client.PostAsJsonAsync("/api/v1/sync/batches", BuildBatch());
|
||||
var batchId = (await upload.Content.ReadFromJsonAsync<ApiResponse<ClinicalBatchUploadResponse>>())!.Data!.BatchId;
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>()
|
||||
.ProcessBatchAsync(batchId, CancellationToken.None);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var gw = await db.WardGateways.FindAsync(_gatewayId);
|
||||
gw!.LastSyncAt.Should().NotBeNull();
|
||||
gw.ReportedBufferDepth.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
["RabbitMq:Password"] = "guest",
|
||||
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
||||
["Fhir:ApiKey"] = "dev-integration-key-change-in-production",
|
||||
["ApiKey:Gateway"] = GatewayAuthHelper.DevGatewayKey,
|
||||
});
|
||||
|
||||
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
|
||||
|
||||
@@ -12,6 +12,10 @@ public static class DbResetHelper
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
DELETE FROM phi_access_logs;
|
||||
DELETE FROM clinical_sync_conflicts;
|
||||
DELETE FROM clinical_sync_batches;
|
||||
DELETE FROM ward_gateways;
|
||||
DELETE FROM clinical_sites;
|
||||
DELETE FROM medication_administrations;
|
||||
DELETE FROM sepsis_bundle_elements;
|
||||
DELETE FROM sepsis_bundles;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
public static class GatewayAuthHelper
|
||||
{
|
||||
public const string DevGatewayKey = "dev-gateway-key-change-in-production";
|
||||
|
||||
public static void WithGatewayApiKey(
|
||||
this HttpClient client, Guid gatewayId, string? apiKey = null)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("X-Api-Key");
|
||||
client.DefaultRequestHeaders.Remove("X-Gateway-Id");
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey ?? DevGatewayKey);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", gatewayId.ToString());
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class PhiEncryptionTests
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var raw = await db.Database
|
||||
.SqlQueryRaw<string>($"SELECT first_name AS \"Value\" FROM patients WHERE id = '{patientId}'")
|
||||
.SqlQuery<string>($"SELECT first_name AS \"Value\" FROM patients WHERE id = {patientId}")
|
||||
.FirstAsync();
|
||||
raw.Should().NotBe("Encrypted");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user