Clinical Sync Batch Engine: first commit

This commit is contained in:
voltsrage
2026-06-23 03:02:30 +08:00
parent 90b8baa2a1
commit c9994b1ba2
60 changed files with 3547 additions and 31 deletions
+3 -2
View File
@@ -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");