feature: Site & Gateway Registry + Clinical Sync Contracts

This commit is contained in:
voltsrage
2026-06-23 04:31:15 +08:00
parent c9994b1ba2
commit efe5419da4
24 changed files with 547 additions and 19 deletions
@@ -0,0 +1,30 @@
using System.Text.Json;
using FluentAssertions;
using VigilCare.ClinicalContracts.Sync;
public class ClinicalContractsTests
{
[Fact]
public void ClinicalSyncBatchRequest_RoundTripsJson()
{
var original = new ClinicalSyncBatchRequest(
BatchReference: Guid.NewGuid(),
GatewayId: Guid.NewGuid(),
SiteId: Guid.NewGuid(),
CapturedAtUtc: DateTimeOffset.UtcNow,
Observations: [
new SyncedObservation(
Guid.NewGuid(), "key-001", Guid.NewGuid(),
"HEART_RATE", 118m, "bpm", "bedside_monitor",
DateTimeOffset.UtcNow)
],
AlertEvents: [],
AlertAcknowledgments: [],
AlertResolutions: []);
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<ClinicalSyncBatchRequest>(json);
restored.Should().BeEquivalentTo(original);
}
}
@@ -40,10 +40,18 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30)); _fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30));
using var scope = _fixture.Services.CreateScope(); SepsisBundle? bundle = null;
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
var bundle = await db.SepsisBundles.SingleAsync(b => b.EncounterId == encounterId); while (DateTime.UtcNow < deadline)
bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS"); {
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
bundle = await db.SepsisBundles.FirstOrDefaultAsync(b => b.EncounterId == encounterId);
if (bundle is not null) break;
await Task.Delay(500);
}
bundle.Should().NotBeNull("expected sepsis bundle for encounter after SOFA_SEPSIS alert");
bundle!.TriggeringAlertType.Should().Be("SOFA_SEPSIS");
} }
[Fact] [Fact]
@@ -0,0 +1,97 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
[Collection("Integration")]
public class GatewayRegistryTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
private Guid _siteId;
private Guid _gatewayId;
public GatewayRegistryTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
_client.AsAdmin();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
await GatewayRegistrySeeder.SeedAsync(db);
_siteId = GatewayRegistrySeeder.DemoSiteId;
_gatewayId = GatewayRegistrySeeder.DemoGatewayId;
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RegisterGateway_UnderSite_Returns201()
{
var resp = await _client.PostAsJsonAsync(
$"/api/v1/sites/{_siteId}/gateways",
new { gatewayCode = "GW-ICU-1A", department = "ICU" });
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
body!.Data!.GatewayCode.Should().Be("GW-ICU-1A");
}
[Fact]
public async Task Heartbeat_UpdatesStatusAndBufferDepth()
{
_client.WithGatewayApiKey(_gatewayId);
var resp = await _client.PatchAsJsonAsync(
$"/api/v1/gateways/{_gatewayId}/heartbeat",
new { status = "ONLINE", bufferDepth = 42, reportedAtUtc = DateTimeOffset.UtcNow });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
_client.AsAdmin();
var get = await _client.GetAsync($"/api/v1/gateways/{_gatewayId}");
var body = await get.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
body!.Data!.Status.Should().Be("ONLINE");
body.Data.ReportedBufferDepth.Should().Be(42);
body.Data.LastHeartbeatAt.Should().NotBeNull();
}
[Fact]
public async Task Heartbeat_InvalidApiKey_Returns401()
{
_client.WithGatewayApiKey(_gatewayId, apiKey: "wrong-key");
var resp = await _client.PatchAsJsonAsync(
$"/api/v1/gateways/{_gatewayId}/heartbeat",
new { status = "ONLINE", bufferDepth = 0, reportedAtUtc = DateTimeOffset.UtcNow });
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Heartbeat_DegradedStatus_Persisted()
{
_client.WithGatewayApiKey(_gatewayId);
await _client.PatchAsJsonAsync(
$"/api/v1/gateways/{_gatewayId}/heartbeat",
new { status = "DEGRADED", bufferDepth = 847, reportedAtUtc = DateTimeOffset.UtcNow });
_client.AsAdmin();
var get = await _client.GetAsync($"/api/v1/gateways/{_gatewayId}");
var body = await get.Content.ReadFromJsonAsync<ApiResponse<WardGatewayResponse>>();
body!.Data!.Status.Should().Be("DEGRADED");
body.Data.ReportedBufferDepth.Should().Be(847);
}
[Fact]
public async Task ListGateways_FilterByDepartment()
{
await _client.PostAsJsonAsync(
$"/api/v1/sites/{_siteId}/gateways",
new { gatewayCode = "GW-GM-8B", department = "GENERAL_MEDICINE" });
var resp = await _client.GetAsync($"/api/v1/sites/{_siteId}/gateways?department=ICU");
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<List<WardGatewayResponse>>>();
body!.Data.Should().OnlyContain(g => g.Department == "ICU");
}
}
@@ -11,14 +11,23 @@ public static class AuthHelper
} }
} }
public static void AsAdmin(this HttpClient client) => public static void AsAdmin(this HttpClient client)
{
client.DefaultRequestHeaders.Remove("X-Test-Role");
client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN"); client.DefaultRequestHeaders.Add("X-Test-Role", "ADMIN");
}
public static void AsPhysician(this HttpClient client) => public static void AsPhysician(this HttpClient client)
{
client.DefaultRequestHeaders.Remove("X-Test-Role");
client.DefaultRequestHeaders.Add("X-Test-Role", "PHYSICIAN"); client.DefaultRequestHeaders.Add("X-Test-Role", "PHYSICIAN");
}
public static void AsIntegration(this HttpClient client) => public static void AsIntegration(this HttpClient client)
{
client.DefaultRequestHeaders.Remove("X-Test-Role");
client.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION"); client.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
}
public static void ClearAuth(this HttpClient client) public static void ClearAuth(this HttpClient client)
{ {
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
public sealed class WardGatewayMetricsCollector : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<WardGatewayMetricsCollector> _logger;
public WardGatewayMetricsCollector(
IServiceScopeFactory scopes,
ClinicalMetrics metrics,
ILogger<WardGatewayMetricsCollector> logger)
{
_scopes = scopes;
_metrics = metrics;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
while (await timer.WaitForNextTickAsync(ct))
await CollectAsync(ct);
}
private async Task CollectAsync(CancellationToken ct)
{
try
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var offlineBySite = await db.WardGateways
.Include(g => g.Site)
.Where(g => g.Status != GatewayStatus.Online)
.GroupBy(g => g.Site.SiteCode)
.Select(g => new { SiteCode = g.Key, Count = g.Count() })
.ToListAsync(ct);
foreach (var row in offlineBySite)
_metrics.WardGatewaysOffline.WithLabels(row.SiteCode).Set(row.Count);
var allGateways = await db.WardGateways
.AsNoTracking()
.ToListAsync(ct);
foreach (var g in allGateways)
_metrics.WardGatewayBufferDepth
.WithLabels(g.GatewayCode, g.Department)
.Set(g.ReportedBufferDepth);
}
catch (Exception ex)
{
_logger.LogError(ex, "WardGatewayMetricsCollector failed");
}
}
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using VigilCare.ClinicalContracts.Sync;
[ApiController]
[Produces("application/json")]
public class GatewaysController : ControllerBase
{
private readonly IGatewayRegistryService _gateways;
public GatewaysController(IGatewayRegistryService gateways) => _gateways = gateways;
[HttpPost("api/v1/sites/{siteId:guid}/gateways")]
[Authorize]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<WardGatewayResponse>), StatusCodes.Status201Created)]
public async Task<IActionResult> Register(Guid siteId, [FromBody] RegisterGatewayRequest req)
{
var gateway = await _gateways.RegisterAsync(siteId, req);
return StatusCode(201, ApiResponse<WardGatewayResponse>.Created(MapGateway(gateway)));
}
[HttpGet("api/v1/sites/{siteId:guid}/gateways")]
[Authorize]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
public async Task<IActionResult> ListBySite(Guid siteId, [FromQuery] string? department)
{
var gateways = await _gateways.ListBySiteAsync(siteId, department);
return Ok(ApiResponse<List<WardGatewayResponse>>.Ok(gateways.Select(MapGateway).ToList()));
}
[HttpGet("api/v1/gateways/{gatewayId:guid}")]
[Authorize]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
public async Task<IActionResult> Get(Guid gatewayId)
{
var gateway = await _gateways.GetByIdAsync(gatewayId);
return Ok(ApiResponse<WardGatewayResponse>.Ok(MapGateway(gateway)));
}
[HttpPatch("api/v1/gateways/{gatewayId:guid}/heartbeat")]
[Authorize(AuthenticationSchemes = GatewayApiKeyAuthenticationHandler.SchemeName)]
public async Task<IActionResult> Heartbeat(Guid gatewayId, [FromBody] GatewayHeartbeatRequest req)
{
var authGatewayId = User.FindFirstValue("gateway_id");
var gateway = await _gateways.RecordHeartbeatAsync(gatewayId, req, authGatewayId);
return Ok(ApiResponse<WardGatewayResponse>.Ok(MapGateway(gateway)));
}
private static WardGatewayResponse MapGateway(WardGateway g) =>
new(g.Id, g.SiteId, g.GatewayCode, g.Department,
g.Status.ToDbString(), g.ReportedBufferDepth,
g.LastHeartbeatAt, g.LastSyncAt);
}
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/v1/sites")]
[Produces("application/json")]
[Authorize]
public class SitesController : ControllerBase
{
private readonly ISiteService _sites;
public SitesController(ISiteService sites) => _sites = sites;
[HttpPost]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<SiteResponse>), StatusCodes.Status201Created)]
public async Task<IActionResult> Create([FromBody] CreateSiteRequest req)
{
var site = await _sites.CreateAsync(req);
return StatusCode(201, ApiResponse<SiteResponse>.Created(MapSite(site)));
}
[HttpGet]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
public async Task<IActionResult> List()
{
var sites = await _sites.ListAsync();
return Ok(ApiResponse<List<SiteResponse>>.Ok(sites.Select(MapSite).ToList()));
}
[HttpGet("{siteId:guid}")]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
public async Task<IActionResult> Get(Guid siteId)
{
var site = await _sites.GetByIdAsync(siteId);
return Ok(ApiResponse<SiteResponse>.Ok(MapSite(site)));
}
private static SiteResponse MapSite(ClinicalSite s) =>
new(s.Id, s.SiteCode, s.Name, s.Address, s.Active, s.CreatedAt);
}
@@ -19,7 +19,8 @@ public class ClinicalSyncBatchConfiguration : IEntityTypeConfiguration<ClinicalS
.HasColumnName("status") .HasColumnName("status")
.HasMaxLength(16) .HasMaxLength(16)
.HasConversion(v => v.ToDbString(), v => ClinicalSyncBatchStatusExtensions.FromDbString(v)) .HasConversion(v => v.ToDbString(), v => ClinicalSyncBatchStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'RECEIVED'"); .HasDefaultValueSql("'RECEIVED'")
.HasSentinel((ClinicalSyncBatchStatus)(-1));
builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb"); builder.Property(b => b.Payload).HasColumnName("payload").HasColumnType("jsonb");
builder.Property(b => b.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()"); builder.Property(b => b.SubmittedAt).HasColumnName("submitted_at").HasDefaultValueSql("NOW()");
builder.Property(b => b.ProcessedAt).HasColumnName("processed_at"); builder.Property(b => b.ProcessedAt).HasColumnName("processed_at");
@@ -19,7 +19,8 @@ public class WardGatewayConfiguration : IEntityTypeConfiguration<WardGateway>
.HasColumnName("status") .HasColumnName("status")
.HasMaxLength(16) .HasMaxLength(16)
.HasConversion(v => v.ToDbString(), v => GatewayStatusExtensions.FromDbString(v)) .HasConversion(v => v.ToDbString(), v => GatewayStatusExtensions.FromDbString(v))
.HasDefaultValueSql("'OFFLINE'"); .HasDefaultValueSql("'OFFLINE'")
.HasSentinel((GatewayStatus)(-1));
builder.Property(g => g.ReportedBufferDepth).HasColumnName("reported_buffer_depth").HasDefaultValue(0); builder.Property(g => g.ReportedBufferDepth).HasColumnName("reported_buffer_depth").HasDefaultValue(0);
builder.Property(g => g.LastHeartbeatAt).HasColumnName("last_heartbeat_at"); builder.Property(g => g.LastHeartbeatAt).HasColumnName("last_heartbeat_at");
builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at"); builder.Property(g => g.LastSyncAt).HasColumnName("last_sync_at");
@@ -0,0 +1 @@
public record RegisterGatewayRequest(string GatewayCode, string Department);
@@ -0,0 +1,4 @@
public record WardGatewayResponse(
Guid Id, Guid SiteId, string GatewayCode, string Department,
string Status, int ReportedBufferDepth,
DateTimeOffset? LastHeartbeatAt, DateTimeOffset? LastSyncAt);
@@ -0,0 +1 @@
public record CreateSiteRequest(string SiteCode, string Name, string? Address);
@@ -0,0 +1,3 @@
public record SiteResponse(
Guid Id, string SiteCode, string Name, string? Address,
bool Active, DateTimeOffset CreatedAt);
@@ -153,4 +153,14 @@ public sealed class ClinicalMetrics
public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge( public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge(
"outbox_pending_events", "outbox_pending_events",
"Count of outbox events not yet relayed to Kafka."); "Count of outbox events not yet relayed to Kafka.");
}
public readonly Gauge WardGatewaysOffline = Metrics.CreateGauge(
"ward_gateways_offline_gauge",
"Ward gateways with status OFFLINE or DEGRADED",
labelNames: new[] { "site_code" });
public readonly Gauge WardGatewayBufferDepth = Metrics.CreateGauge(
"ward_gateway_buffer_depth",
"Reported unsynced event count per gateway",
labelNames: new[] { "gateway_code", "department" });
}
+4
View File
@@ -203,6 +203,9 @@ try
builder.Services.AddScoped<ClinicalSyncBatchProcessor>(); builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
builder.Services.AddSingleton<IPhiEncryptionService, PhiEncryptionService>(); builder.Services.AddSingleton<IPhiEncryptionService, PhiEncryptionService>();
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>(); builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
builder.Services.AddHostedService<ThresholdCacheLoader>(); builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>(); builder.Services.AddHostedService<KafkaTopicProvisioner>();
builder.Services.AddHostedService<OutboxRelayService>(); builder.Services.AddHostedService<OutboxRelayService>();
@@ -227,6 +230,7 @@ try
builder.Services.AddHostedService<GcsScoringService>(); builder.Services.AddHostedService<GcsScoringService>();
builder.Services.AddHostedService<SofaScoringService>(); builder.Services.AddHostedService<SofaScoringService>();
builder.Services.AddHostedService<PatientPhiMigrationService>(); builder.Services.AddHostedService<PatientPhiMigrationService>();
builder.Services.AddHostedService<WardGatewayMetricsCollector>();
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" }) .AddDbContextCheck<AppDbContext>("postgresql", tags: new[] { "ready" })
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
public class GatewayRegistryService : IGatewayRegistryService
{
private readonly AppDbContext _db;
public GatewayRegistryService(AppDbContext db) => _db = db;
public async Task<WardGateway> RegisterAsync(Guid siteId, RegisterGatewayRequest req)
{
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId);
if (!siteExists)
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
var duplicate = await _db.WardGateways.AnyAsync(g =>
g.SiteId == siteId && g.GatewayCode == req.GatewayCode);
if (duplicate)
throw new ConflictException(
$"Gateway code '{req.GatewayCode}' already exists for this site.",
"GATEWAY_CODE_CONFLICT");
var gateway = new WardGateway(siteId, req.GatewayCode, req.Department);
_db.WardGateways.Add(gateway);
await _db.SaveChangesAsync();
return gateway;
}
public async Task<WardGateway> GetByIdAsync(Guid id)
{
var gateway = await _db.WardGateways
.AsNoTracking()
.Include(g => g.Site)
.FirstOrDefaultAsync(g => g.Id == id);
if (gateway is null)
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
return gateway;
}
public async Task<List<WardGateway>> ListBySiteAsync(Guid siteId, string? department)
{
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId);
if (!siteExists)
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
var query = _db.WardGateways.AsNoTracking().Where(g => g.SiteId == siteId);
if (!string.IsNullOrWhiteSpace(department))
query = query.Where(g => g.Department == department);
return await query.OrderBy(g => g.GatewayCode).ToListAsync();
}
public async Task<WardGateway> RecordHeartbeatAsync(
Guid gatewayId, GatewayHeartbeatRequest req, string? authenticatedGatewayId)
{
if (authenticatedGatewayId is not null
&& Guid.TryParse(authenticatedGatewayId, out var authId)
&& authId != gatewayId)
throw new ValidationException(
"Gateway id in URL does not match X-Gateway-Id header.",
"GATEWAY_ID_MISMATCH");
var gateway = await _db.WardGateways.FindAsync(gatewayId);
if (gateway is null)
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
var status = GatewayStatusExtensions.FromDbString(req.Status.ToUpperInvariant());
gateway.RecordHeartbeat(status, req.BufferDepth, req.ReportedAtUtc);
await _db.SaveChangesAsync();
return gateway;
}
public async Task MarkOfflineAsync(Guid gatewayId)
{
var gateway = await _db.WardGateways.FindAsync(gatewayId);
if (gateway is null)
throw new NotFoundException("Ward gateway not found.", "GATEWAY_NOT_FOUND");
gateway.MarkOffline();
await _db.SaveChangesAsync();
}
}
@@ -0,0 +1,8 @@
public interface IGatewayRegistryService
{
Task<WardGateway> RegisterAsync(Guid siteId, RegisterGatewayRequest req);
Task<WardGateway> GetByIdAsync(Guid id);
Task<List<WardGateway>> ListBySiteAsync(Guid siteId, string? department);
Task<WardGateway> RecordHeartbeatAsync(Guid gatewayId, GatewayHeartbeatRequest req, string? authenticatedGatewayId);
Task MarkOfflineAsync(Guid gatewayId);
}
@@ -0,0 +1,6 @@
public interface ISiteService
{
Task<ClinicalSite> CreateAsync(CreateSiteRequest req);
Task<ClinicalSite> GetByIdAsync(Guid id);
Task<List<ClinicalSite>> ListAsync();
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
public class SiteService : ISiteService
{
private readonly AppDbContext _db;
public SiteService(AppDbContext db) => _db = db;
public async Task<ClinicalSite> CreateAsync(CreateSiteRequest req)
{
var exists = await _db.ClinicalSites.AnyAsync(s => s.SiteCode == req.SiteCode);
if (exists)
throw new ConflictException(
$"Site code '{req.SiteCode}' is already registered.",
"SITE_CODE_CONFLICT");
var site = new ClinicalSite(req.SiteCode, req.Name, req.Address);
_db.ClinicalSites.Add(site);
await _db.SaveChangesAsync();
return site;
}
public async Task<ClinicalSite> GetByIdAsync(Guid id)
{
var site = await _db.ClinicalSites
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == id);
if (site is null)
throw new NotFoundException("Clinical site not found.", "SITE_NOT_FOUND");
return site;
}
public async Task<List<ClinicalSite>> ListAsync() =>
await _db.ClinicalSites.AsNoTracking()
.OrderBy(s => s.SiteCode)
.ToListAsync();
}
@@ -0,0 +1,12 @@
using FluentValidation;
public class CreateSiteRequestValidator : AbstractValidator<CreateSiteRequest>
{
public CreateSiteRequestValidator()
{
RuleFor(x => x.SiteCode).NotEmpty().MaximumLength(32)
.Matches("^[A-Z0-9-]+$").WithMessage("SiteCode must be uppercase alphanumeric with hyphens.");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Address).MaximumLength(500);
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
using VigilCare.ClinicalContracts.Sync;
public class GatewayHeartbeatRequestValidator : AbstractValidator<GatewayHeartbeatRequest>
{
private static readonly string[] AllowedStatuses = ["ONLINE", "DEGRADED", "OFFLINE"];
public GatewayHeartbeatRequestValidator()
{
RuleFor(x => x.Status).NotEmpty()
.Must(s => AllowedStatuses.Contains(s.ToUpperInvariant()))
.WithMessage("Status must be ONLINE, DEGRADED, or OFFLINE.");
RuleFor(x => x.BufferDepth).GreaterThanOrEqualTo(0);
RuleFor(x => x.ReportedAtUtc).NotEmpty();
}
}
@@ -0,0 +1,10 @@
using FluentValidation;
public class RegisterGatewayRequestValidator : AbstractValidator<RegisterGatewayRequest>
{
public RegisterGatewayRequestValidator()
{
RuleFor(x => x.GatewayCode).NotEmpty().MaximumLength(64);
RuleFor(x => x.Department).NotEmpty().MaximumLength(100);
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "==> Build contracts"
dotnet build VigilCare.ClinicalContracts
echo "==> Run gateway registry tests"
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~GatewayRegistry" --no-build 2>/dev/null \
|| dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~GatewayRegistry"
API="${API_BASE_URL:-http://localhost:5080}"
KEY="${GATEWAY_API_KEY:-dev-gateway-key-change-in-production}"
# Obtain admin JWT (adjust to your local login endpoint)
TOKEN="${ADMIN_JWT:?Set ADMIN_JWT to a valid admin bearer token}"
echo "==> Create site"
SITE_RESP=$(curl -sf -X POST "$API/api/v1/sites" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"siteCode":"SITE-VERIFY","name":"Verification Hospital"}')
SITE_ID=$(echo "$SITE_RESP" | jq -r '.data.id')
echo "==> Register gateway"
GW_RESP=$(curl -sf -X POST "$API/api/v1/sites/$SITE_ID/gateways" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"gatewayCode":"GW-VERIFY-01","department":"ICU"}')
GW_ID=$(echo "$GW_RESP" | jq -r '.data.id')
echo "==> Heartbeat"
curl -sf -X PATCH "$API/api/v1/gateways/$GW_ID/heartbeat" \
-H "X-Api-Key: $KEY" \
-H "X-Gateway-Id: $GW_ID" \
-H "Content-Type: application/json" \
-d '{"status":"ONLINE","bufferDepth":3,"reportedAtUtc":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
echo "==> Verify gateway status"
GET_RESP=$(curl -sf "$API/api/v1/gateways/$GW_ID" \
-H "Authorization: Bearer $TOKEN")
echo "$GET_RESP" | jq -e '.data.status == "ONLINE" and .data.reportedBufferDepth == 3'
echo "Phase 20 verification passed."
-9
View File
@@ -1,6 +1,3 @@
**`scripts/run-phase22-verification.sh`:**
```bash
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -13,9 +10,6 @@ KEY="${GATEWAY_API_KEY:-dev-gateway-key-change-in-production}"
GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}" GW_ID="${GATEWAY_ID:-22222222-2222-2222-2222-222222222222}"
JWT="${ADMIN_JWT:?Set ADMIN_JWT}" JWT="${ADMIN_JWT:?Set ADMIN_JWT}"
echo "==> Run sync batch tests"
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~ClinicalSyncBatch"
echo "==> Start full stack" echo "==> Start full stack"
docker compose --profile full --profile ward-gateway up -d docker compose --profile full --profile ward-gateway up -d
sleep 30 sleep 30
@@ -54,6 +48,3 @@ docker exec -i $(docker ps -qf name=postgres) psql -U postgres -d vigilcare \
echo "==> Check no duplicate paging (grep application logs for [PAGE] count)" echo "==> Check no duplicate paging (grep application logs for [PAGE] count)"
echo "Phase 22 verification passed." echo "Phase 22 verification passed."
```
Make executable: `chmod +x scripts/run-phase22-verification.sh`