183 lines
6.2 KiB
C#
183 lines
6.2 KiB
C#
using System.Globalization;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StackExchange.Redis;
|
|
|
|
[Collection("Integration")]
|
|
public class ObservabilityPhase8Tests
|
|
{
|
|
private static readonly string[] ExpectedMetrics =
|
|
{
|
|
"observations_ingested_total",
|
|
"observation_ingest_duration_seconds",
|
|
"clinical_alerts_total",
|
|
"alerts_unacknowledged_gauge",
|
|
"kafka_consumer_lag",
|
|
"outbox_pending_events",
|
|
"qsofa_detections_total",
|
|
"escalations_total",
|
|
};
|
|
|
|
private readonly ApiFixture _fixture;
|
|
private readonly HttpClient _http;
|
|
|
|
public ObservabilityPhase8Tests(ApiFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_http = fixture.CreateClient();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MetricsEndpoint_ReturnsAllEightMetricFamilies()
|
|
{
|
|
var resp = await _http.GetAsync("/metrics");
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
Assert.Equal("text/plain", resp.Content.Headers.ContentType?.MediaType);
|
|
|
|
var body = await resp.Content.ReadAsStringAsync();
|
|
|
|
foreach (var metric in ExpectedMetrics)
|
|
{
|
|
Assert.Contains(metric, body);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrelationMiddleware_AddsXCorrelationIdHeader()
|
|
{
|
|
var resp = await _http.GetAsync("/api/v1/alert-thresholds");
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
Assert.True(resp.Headers.Contains("X-Correlation-Id"),
|
|
"X-Correlation-Id response header is missing.");
|
|
var value = resp.Headers.GetValues("X-Correlation-Id").First();
|
|
Assert.NotEmpty(value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrelationMiddleware_EchoesIncomingCorrelationId()
|
|
{
|
|
var correlationId = "test-correlation-abc123";
|
|
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/alert-thresholds");
|
|
req.Headers.Add("X-Correlation-Id", correlationId);
|
|
|
|
var resp = await _http.SendAsync(req);
|
|
resp.EnsureSuccessStatusCode();
|
|
|
|
var echoed = resp.Headers.GetValues("X-Correlation-Id").First();
|
|
Assert.Equal(correlationId, echoed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ObservationIngest_IncrementsObservationsIngestedTotal()
|
|
{
|
|
var beforeBody = await (await _http.GetAsync("/metrics")).Content.ReadAsStringAsync();
|
|
var before = ParseCounterValue(beforeBody, "observations_ingested_total");
|
|
|
|
await EnsureHeartRateThresholdAsync();
|
|
var encounterId = await CreateActiveEncounterAsync();
|
|
|
|
var ingestResp = await _http.PostAsJsonAsync(
|
|
$"/api/v1/encounters/{encounterId}/observations",
|
|
new BatchIngestRequest(new List<IngestObservationRequest>
|
|
{
|
|
new("HEART_RATE", 72, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null)
|
|
}));
|
|
ingestResp.EnsureSuccessStatusCode();
|
|
|
|
var afterBody = await (await _http.GetAsync("/metrics")).Content.ReadAsStringAsync();
|
|
var after = ParseCounterValue(afterBody, "observations_ingested_total");
|
|
|
|
Assert.True(after > before,
|
|
$"observations_ingested_total did not increase. Before={before} After={after}");
|
|
}
|
|
|
|
private async Task EnsureHeartRateThresholdAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var existing = await db.AlertThresholds
|
|
.FirstOrDefaultAsync(t => t.ObservationCode == "HEART_RATE");
|
|
|
|
if (existing is null)
|
|
{
|
|
db.AlertThresholds.Add(new AlertThreshold
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ObservationCode = "HEART_RATE",
|
|
DisplayName = "Heart Rate",
|
|
Unit = "bpm",
|
|
CriticalLow = 30,
|
|
WarningLow = 50,
|
|
WarningHigh = 100,
|
|
CriticalHigh = 150,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
var cache = redis.GetDatabase(1);
|
|
await cache.StringSetAsync("threshold:HEART_RATE",
|
|
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
|
|
}
|
|
|
|
// Parse the sum of all label combinations for a counter family.
|
|
private static double ParseCounterValue(string metricsBody, string metricName)
|
|
{
|
|
double total = 0;
|
|
foreach (var rawLine in metricsBody.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var line = rawLine.Trim();
|
|
if (line.StartsWith('#')) continue;
|
|
if (!line.StartsWith(metricName, StringComparison.Ordinal)) continue;
|
|
|
|
var valueToken = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
|
if (valueToken is null) continue;
|
|
if (double.TryParse(valueToken, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
|
|
{
|
|
total += value;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
private async Task<Guid> CreateActiveEncounterAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Mrn = $"MRN-METRICS-{Guid.NewGuid():N}"[..18],
|
|
FirstName = "Metrics",
|
|
LastName = "Test",
|
|
DateOfBirth = new DateOnly(1975, 3, 20),
|
|
Gender = "Female",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PatientId = patient.Id,
|
|
EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active,
|
|
Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Osei",
|
|
AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
await db.SaveChangesAsync();
|
|
|
|
return encounter.Id;
|
|
}
|
|
}
|