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(); await DbResetHelper.ResetAsync(db); await GatewayRegistrySeeder.SeedAsync(db); await DataSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService()); _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(); (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>(); var second = await _client.PostAsJsonAsync("/api/v1/sync/batches", batch); var secondBody = await second.Content.ReadFromJsonAsync>(); secondBody!.Data!.BatchId.Should().Be(firstBody!.Data!.BatchId); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); (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>())!.Data!.BatchId; using var scope = _fixture.Services.CreateScope(); var processor = scope.ServiceProvider.GetRequiredService(); await processor.ProcessBatchAsync(batchId, CancellationToken.None); var db = scope.ServiceProvider.GetRequiredService(); (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>())!.Data!.BatchId; using var scope = _fixture.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService() .ProcessBatchAsync(batchId, CancellationToken.None); var status = await _client.GetAsync($"/api/v1/sync/batches/{batchId}"); var body = await status.Content.ReadFromJsonAsync>(); 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>())!.Data!.BatchId; using var scope = _fixture.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService() .ProcessBatchAsync(batchId, CancellationToken.None); var db = scope.ServiceProvider.GetRequiredService(); var gw = await db.WardGateways.FindAsync(_gatewayId); gw!.LastSyncAt.Should().NotBeNull(); gw.ReportedBufferDepth.Should().Be(0); } }