feature: Ward Gateway Service (Local-First Clinical Path)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class GatewayApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
public const string TestConnectionString =
|
||||
"Host=localhost;Port=5437;Database=vigilcare_ward_test;Username=postgres;Password=password";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:GatewayDb"] = TestConnectionString,
|
||||
["Redis:ConnectionString"] = "localhost:6383,allowAdmin=true",
|
||||
["RabbitMq:Host"] = "localhost",
|
||||
["RabbitMq:Port"] = "5675",
|
||||
["RabbitMq:Username"] = "guest",
|
||||
["RabbitMq:Password"] = "guest",
|
||||
["RabbitMq:PagingAckTimeoutMs"] = "5000",
|
||||
["CentralApi:BaseUrl"] = "http://127.0.0.1:1",
|
||||
["Gateway:GatewayId"] = "22222222-2222-2222-2222-222222222222",
|
||||
["Gateway:SiteId"] = "11111111-1111-1111-1111-111111111111",
|
||||
["Gateway:Department"] = "ICU",
|
||||
["Gateway:EncounterSyncIntervalMinutes"] = "60",
|
||||
["Gateway:CentralReachabilityIntervalSeconds"] = "1",
|
||||
["Gateway:HeartbeatIntervalSeconds"] = "1",
|
||||
["Gateway:SyncBatchSize"] = "500",
|
||||
["ApiKey:Gateway"] = "dev-gateway-key-change-in-production",
|
||||
["Jwt:SigningKey"] = "dev-signing-key-minimum-32-bytes-long!!",
|
||||
["Jwt:Issuer"] = "vigilcare-gateway",
|
||||
["Jwt:Audience"] = "vigilcare-dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestingAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestingAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestingAuthHandler>(
|
||||
TestingAuthHandler.SchemeName, _ => { });
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<GatewayDbContext>()
|
||||
.UseNpgsql(TestConnectionString)
|
||||
.Options;
|
||||
await using (var migrateDb = new GatewayDbContext(options))
|
||||
{
|
||||
await migrateDb.Database.MigrateAsync();
|
||||
await GatewayDbResetHelper.ResetAsync(migrateDb);
|
||||
}
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var server = redis.GetServer(redis.GetEndPoints().First());
|
||||
await server.FlushAllDatabasesAsync();
|
||||
}
|
||||
|
||||
protected override void ConfigureClient(HttpClient client)
|
||||
{
|
||||
base.ConfigureClient(client);
|
||||
client.AsNurse();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync() => await base.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[CollectionDefinition("GatewayIntegration")]
|
||||
public class GatewayIntegrationCollection : ICollectionFixture<GatewayApiFixture>;
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public static class GatewayDbResetHelper
|
||||
{
|
||||
public static async Task ResetAsync(GatewayDbContext db)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(@"
|
||||
DELETE FROM buffered_sync_items;
|
||||
DELETE FROM sync_outbox;
|
||||
DELETE FROM clinical_alerts;
|
||||
DELETE FROM observations;
|
||||
DELETE FROM encounters;
|
||||
DELETE FROM patients;
|
||||
DELETE FROM alert_thresholds;
|
||||
DELETE FROM gateway_sync_state;
|
||||
");
|
||||
return;
|
||||
}
|
||||
catch when (attempt < 5)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class GatewayTestSeeder
|
||||
{
|
||||
public static async Task<(Guid PatientId, Guid EncounterId)> SeedActiveEncounterAsync(
|
||||
IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
await GatewayDbResetHelper.ResetAsync(db);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var patient = new ReplicaPatient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mrn = "MRN-GW-001",
|
||||
FirstName = "Gateway",
|
||||
LastName = "Test",
|
||||
DateOfBirth = new DateOnly(1970, 1, 1),
|
||||
Gender = "F",
|
||||
SyncedAt = now
|
||||
};
|
||||
var encounter = new ReplicaEncounter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PatientId = patient.Id,
|
||||
EncounterType = EncounterType.Inpatient,
|
||||
Status = EncounterStatus.Active,
|
||||
Department = Department.Icu,
|
||||
AttendingPhysician = "Dr. Gateway",
|
||||
RoomBed = "ICU-01",
|
||||
AdmittedAt = now,
|
||||
SyncedAt = now
|
||||
};
|
||||
db.Patients.Add(patient);
|
||||
db.Encounters.Add(encounter);
|
||||
db.AlertThresholds.Add(new ReplicaAlertThreshold
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ObservationCode = "HEART_RATE",
|
||||
DisplayName = "Heart Rate",
|
||||
Unit = "bpm",
|
||||
CriticalLow = 30,
|
||||
WarningLow = 50,
|
||||
WarningHigh = 100,
|
||||
CriticalHigh = 150,
|
||||
SyncedAt = now
|
||||
});
|
||||
db.SyncState.Add(new GatewaySyncState
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InitialSyncCompleted = true,
|
||||
LastEncounterSyncAt = now
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var cache = redis.GetDatabase();
|
||||
await cache.StringSetAsync(
|
||||
"threshold:HEART_RATE",
|
||||
JsonSerializer.Serialize(new ThresholdCacheEntry(
|
||||
"HEART_RATE", 30, 50, 100, 150)));
|
||||
|
||||
return (patient.Id, encounter.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VigilCare.WardGateway\VigilCare.WardGateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\VigilCareClinicalAPI.Tests\Auth\TestingAuthHandler.cs" Link="Auth\TestingAuthHandler.cs" />
|
||||
<Compile Include="..\VigilCareClinicalAPI.Tests\Helpers\AuthHelper.cs" Link="Helpers\AuthHelper.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
[Collection("GatewayIntegration")]
|
||||
public class WardGatewayLocalPathTests : IAsyncLifetime
|
||||
{
|
||||
private readonly GatewayApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _encounterId;
|
||||
|
||||
public WardGatewayLocalPathTests(GatewayApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
(_, _encounterId) = await GatewayTestSeeder.SeedActiveEncounterAsync(_fixture.Services);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task CriticalObservation_CreatesAlertLocally()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
(await db.ClinicalAlerts.CountAsync()).Should().BeGreaterThan(0);
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateIdempotencyKey_ReturnsExisting()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
var body = new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 80,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = key
|
||||
};
|
||||
await _client.PostAsJsonAsync($"/api/v1/encounters/{_encounterId}/observations", body);
|
||||
var dup = await _client.PostAsJsonAsync($"/api/v1/encounters/{_encounterId}/observations", body);
|
||||
dup.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var json = await dup.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
json!.RootElement.GetProperty("data").GetProperty("duplicate").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_WritesBufferedAck()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
var alertsResp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
alertsResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var alertsDoc = await alertsResp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var alertId = alertsDoc!.RootElement
|
||||
.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
|
||||
var ackResp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alertId}/acknowledge",
|
||||
new AcknowledgeAlertRequest("Reviewing at bedside."));
|
||||
ackResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
(await db.BufferedSyncItems.CountAsync(b =>
|
||||
b.ItemType == BufferedSyncItemType.Ack && !b.Synced)).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDown_ReachabilityReportsDegraded()
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var reachability = scope.ServiceProvider.GetRequiredService<CentralReachabilityService>();
|
||||
reachability.IsCentralReachable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WarningObservation_CreatesWarningNoPage()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 105,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = Guid.NewGuid().ToString()
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alert = await db.ClinicalAlerts.SingleAsync();
|
||||
alert.Severity.Should().Be(AlertSeverity.Warning);
|
||||
alert.AlertType.Should().Be(AlertType.WarningHeartRate);
|
||||
alert.Status.Should().Be(AlertStatus.Open);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Degraded-mode tests — central is unreachable; gateway clinical path remains authoritative locally.
|
||||
/// </summary>
|
||||
[Collection("GatewayIntegration")]
|
||||
public class WardGatewayPartitionTests : IAsyncLifetime
|
||||
{
|
||||
private readonly GatewayApiFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
private Guid _encounterId;
|
||||
|
||||
public WardGatewayPartitionTests(GatewayApiFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
(_, _encounterId) = await GatewayTestSeeder.SeedActiveEncounterAsync(_fixture.Services);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_CriticalIngest_CreatesLocalAlertAndBuffer()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alerts = await db.ClinicalAlerts.Where(a => a.EncounterId == _encounterId).ToListAsync();
|
||||
alerts.Should().ContainSingle();
|
||||
alerts[0].Severity.Should().Be(AlertSeverity.Critical);
|
||||
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_ListAlertsViaGatewayApi()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-list-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
var resp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
doc!.RootElement.GetProperty("data").GetProperty("items").GetArrayLength()
|
||||
.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_ListActiveEncounters_FromLocalReplica()
|
||||
{
|
||||
var resp = await _client.GetAsync("/api/v1/encounters?status=ACTIVE&department=ICU");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var doc = await resp.Content.ReadFromJsonAsync<JsonDocument>();
|
||||
var items = doc!.RootElement.GetProperty("data").GetProperty("items");
|
||||
items.GetArrayLength().Should().BeGreaterThan(0);
|
||||
items[0].GetProperty("encounterId").GetGuid().Should().Be(_encounterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partition_AcknowledgeAndResolve_BuffersLocally()
|
||||
{
|
||||
await _client.PostAsJsonAsync(
|
||||
$"/api/v1/encounters/{_encounterId}/observations",
|
||||
new
|
||||
{
|
||||
observationCode = "HEART_RATE",
|
||||
value = 160,
|
||||
unit = "bpm",
|
||||
source = "DEVICE",
|
||||
recordedAt = DateTimeOffset.UtcNow,
|
||||
idempotencyKey = $"partition-lifecycle-{Guid.NewGuid()}"
|
||||
});
|
||||
|
||||
var alertsResp = await _client.GetAsync($"/api/v1/encounters/{_encounterId}/alerts");
|
||||
var alertId = (await alertsResp.Content.ReadFromJsonAsync<JsonDocument>())!
|
||||
.RootElement.GetProperty("data").GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
|
||||
(await _client.PostAsJsonAsync(
|
||||
$"/api/v1/alerts/{alertId}/acknowledge",
|
||||
new AcknowledgeAlertRequest("Partition ack"))).StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
(await _client.PostAsync($"/api/v1/alerts/{alertId}/resolve", null))
|
||||
.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
var alert = await db.ClinicalAlerts.FindAsync(alertId);
|
||||
alert!.Status.Should().Be(AlertStatus.Resolved);
|
||||
(await db.BufferedSyncItems.CountAsync(b => !b.Synced)).Should().BeGreaterThanOrEqualTo(4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user