using System.Net; using System.Text; using Jiaowu.Api.Infrastructure.Observability; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; namespace Jiaowu.Api.Tests; public sealed class PerformanceReportServiceTests { [Fact] public async Task Configured_source_returns_cached_native_report() { var handler = new PrometheusHandler(); using var httpClient = new HttpClient(handler); using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new PerformanceReportService( httpClient, cache, new PerformanceReportingOptions { Enabled = true, PrometheusBaseUrl = "https://prometheus.test/", BearerToken = "read-only-token", GrafanaBaseUrl = "https://grafana.test/", CacheSeconds = 30 }, new ObservabilityOptions { ServiceName = "jiaowu-api" }, NullLogger.Instance); var first = await service.GetAsync("1h", CancellationToken.None); var second = await service.GetAsync("1h", CancellationToken.None); Assert.Equal("ready", first.Status); Assert.Equal("prometheus", first.DataSource); Assert.Equal("https://grafana.test/", first.DashboardUrl); Assert.NotNull(first.Headline); Assert.Equal(5, first.Headline.RequestCount); Assert.Equal(2, first.Timeline.Count); Assert.Equal( "/api/timetables/classes/{classId}", Assert.Single(first.Endpoints).Name); Assert.Equal( "Timetable.LoadScheduleEntries", Assert.Single(first.DatabaseQueries).Name); Assert.Same(first, second); Assert.Equal(14, handler.RequestCount); Assert.True(handler.AllRequestsAuthenticated); } [Fact] public async Task Missing_source_returns_directed_empty_state() { using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new PerformanceReportService( new HttpClient(new RejectingHandler()), cache, new PerformanceReportingOptions(), new ObservabilityOptions(), NullLogger.Instance); var report = await service.GetAsync("24h", CancellationToken.None); Assert.Equal("not_configured", report.Status); Assert.Contains("Prometheus", report.Detail); Assert.Empty(report.Timeline); } [Fact] public async Task Unsupported_range_is_rejected_before_querying_source() { using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new PerformanceReportService( new HttpClient(new RejectingHandler()), cache, new PerformanceReportingOptions(), new ObservabilityOptions(), NullLogger.Instance); await Assert.ThrowsAsync(() => service.GetAsync("30d", CancellationToken.None)); await Assert.ThrowsAsync(() => service.GetAsync(null, CancellationToken.None)); } [Fact] public async Task Source_timeout_returns_unavailable_report() { using var cache = new MemoryCache(new MemoryCacheOptions()); var service = new PerformanceReportService( new HttpClient(new TimeoutHandler()), cache, new PerformanceReportingOptions { Enabled = true, PrometheusBaseUrl = "https://prometheus.test/" }, new ObservabilityOptions(), NullLogger.Instance); var report = await service.GetAsync("1h", CancellationToken.None); Assert.Equal("unavailable", report.Status); Assert.Contains("暂时不可用", report.Detail); } private sealed class PrometheusHandler : HttpMessageHandler { private int requestCount; private int authenticatedCount; public int RequestCount => requestCount; public bool AllRequestsAuthenticated => authenticatedCount == requestCount; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref requestCount); if (request.Headers.Authorization?.Scheme == "Bearer" && request.Headers.Authorization.Parameter == "read-only-token") { Interlocked.Increment(ref authenticatedCount); } var isRange = request.RequestUri!.AbsolutePath.EndsWith( "/query_range", StringComparison.Ordinal); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var json = isRange ? $$""" { "status": "success", "data": { "resultType": "matrix", "result": [{ "metric": {}, "values": [ [{{now - 60}}, "2"], [{{now}}, "3"] ] }] } } """ : $$""" { "status": "success", "data": { "resultType": "vector", "result": [{ "metric": { "http_route": "/api/timetables/classes/{classId}", "db_query_name": "Timetable.LoadScheduleEntries" }, "value": [{{now}}, "5"] }] } } """; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( json, Encoding.UTF8, "application/json") }); } } private sealed class RejectingHandler : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => throw new InvalidOperationException( "未配置时不应访问外部数据源。"); } private sealed class TimeoutHandler : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => throw new TaskCanceledException("Prometheus query timed out."); } }