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 { 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(); 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(); 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 CreateActiveEncounterAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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; } }