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
@@ -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);
}
}