189 lines
6.8 KiB
C#
189 lines
6.8 KiB
C#
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<PerformanceReportService>.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<PerformanceReportService>.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<PerformanceReportService>.Instance);
|
|
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
|
service.GetAsync("30d", CancellationToken.None));
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
|
|
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<PerformanceReportService>.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<HttpResponseMessage> 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<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken) =>
|
|
throw new InvalidOperationException(
|
|
"未配置时不应访问外部数据源。");
|
|
}
|
|
|
|
private sealed class TimeoutHandler : HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken) =>
|
|
throw new TaskCanceledException("Prometheus query timed out.");
|
|
}
|
|
}
|