using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class MetricsEndpointTests { private readonly ApiFactory _factory; public MetricsEndpointTests(ApiFactory factory) => _factory = factory; private static string RandomBarcode() => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); /// /metrics доступен без авторизации (стандарт Prometheus) и /// выдаёт text/plain в exposition format. [Fact] public async Task Metrics_endpoint_unauthenticated_returns_prometheus_text() { using var client = _factory.CreateClient(); using var resp = await client.GetAsync("/metrics"); resp.IsSuccessStatusCode.Should().BeTrue($"/metrics вернул {(int)resp.StatusCode}"); resp.Content.Headers.ContentType!.MediaType.Should().StartWith("text/plain"); var text = await resp.Content.ReadAsStringAsync(); // process_* + dotnet_* — встроенные коллекторы prometheus-net. text.Should().Contain("process_cpu_seconds_total"); // HTTP-метрика появляется после первого UseHttpMetrics-обработанного запроса // (этот же GET /metrics на пути HTTP-pipeline уже учитан в счётчике). text.Should().Contain("http_requests_received_total"); } /// После проведения чека счётчик sales_posted увеличивается. [Fact] public async Task Sales_posted_counter_increments_after_post() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"metr-sale-{Guid.NewGuid():N}"); var refs = await api.LoadRefsAsync(); var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); // Подкормка склада, чек, проводим. var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 50m } }, }); enter.EnsureSuccessStatusCode(); var eid = (await enter.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); // Снимаем "до". var before = await ReadMetricSample("food_market_sales_posted_total", null); var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new { date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null, customerId = (string?)null, currencyId = refs.CurrencyId, payment = 0, paidCash = 100m, paidCard = 0m, lines = new[] { new { productId = p1, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } }, notes = "sale", }); sale.EnsureSuccessStatusCode(); var sid = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); (await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); var after = await ReadMetricSample("food_market_sales_posted_total", null); after.Should().BeGreaterThan(before); } /// DB-длительность измеряется через интерсептор EF. [Fact] public async Task Db_query_duration_metric_observed() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"metr-db-{Guid.NewGuid():N}"); // Любой запрос с EF в обработчике (signup уже сделал SELECT/INSERT'ы). await api.GetJsonAsync("/api/catalog/products?pageSize=10"); using var client = _factory.CreateClient(); var text = await client.GetStringAsync("/metrics"); text.Should().Contain("food_market_db_query_duration_seconds_count"); } /// Читает значение sample'а метрики (тип counter без labels). /// Возвращает 0 если строки нет. private async Task ReadMetricSample(string metricName, string? label) { using var client = _factory.CreateClient(); var text = await client.GetStringAsync("/metrics"); var prefix = label is null ? metricName : metricName; foreach (var line in text.Split('\n')) { if (line.StartsWith("#")) continue; if (!line.StartsWith(prefix)) continue; // Формат: "metric_name{labels} value timestamp?" или "metric_name value" var parts = line.Trim().Split(' '); if (parts.Length >= 2 && double.TryParse(parts[^1], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)) return v; } return 0; } }