using System.Globalization; using System.Net; using MeterVault.App; using MeterVault.App.Analysis; using MeterVault.Core.Domain; using MeterVault.Integration.Tests.Costing; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Analysis; /// /// GET /export/analysis.csv (D-55) end to end: the page's URL keys resolved by the same query code, quantities /// from the analysis reader and costs from the cost reader, one row per bucket and series with local bounds, the /// comparison beside each value — and a 400 for anything it cannot answer. The app runs on the frozen clock of /// (19 September 2026, 14:37 Berlin). /// [Collection("Timescale")] public sealed class AnalysisExportEndpointTests(TimescaleFixture fx) { [Fact] public async Task A_meter_exports_its_quantity_cost_and_comparison_per_bucket() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2023, 1, 1), 80, 40, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 100, 50); await box.TypePriceAsync(type, 0.30, D(2023, 1, 1)); using var app = new FrozenApp(fx.ConnectionString); using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=meter&id={meter}&from=2024-01-01&to=2024-02-29&bucket=month")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); var disposition = response.Content.Headers.ContentDisposition!; Assert.Equal("attachment", disposition.DispositionType); Assert.Equal($"metervault-meter-{meter}-quantity-2024-01-01-2024-02-29.csv", disposition.FileNameStar ?? disposition.FileName?.Trim('"')); var rows = await RowsAsync(response); Assert.Equal(2, rows.Count); var january = rows[0]; Assert.Equal("m" + meter.ToString(CultureInfo.InvariantCulture), january["series_id"]); Assert.Equal("Consumption", january["kind"]); Assert.Equal("kWh", january["unit"]); Assert.Equal("2024-01-01T00:00:00+01:00", january["bucket_start"]); Assert.Equal("2024-02-01T00:00:00+01:00", january["bucket_end"]); Assert.Equal("Europe/Berlin", january["timezone"]); Assert.Equal(100, Number(january["value"]), 6); Assert.Equal("Available", january["status"]); Assert.Equal(30, Number(january["cost"]), 6); Assert.Equal("Priced", january["cost_status"]); Assert.Equal("EUR", january["currency"]); // The default comparison is the previous year (A-13): January 2023. Assert.Equal(80, Number(january["comparison_value"]), 6); var february = rows[1]; Assert.Equal("2024-02-01T00:00:00+01:00", february["bucket_start"]); Assert.Equal("2024-03-01T00:00:00+01:00", february["bucket_end"]); Assert.Equal(50, Number(february["value"]), 6); Assert.Equal(15, Number(february["cost"]), 6); Assert.Equal(40, Number(february["comparison_value"]), 6); } [Fact] public async Task The_cost_metric_exports_the_scope_s_bill_with_its_comparison() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2023, 1, 1), 80, 40, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 100, 50); await box.TypePriceAsync(type, 0.30, D(2023, 1, 1)); using var app = new FrozenApp(fx.ConnectionString); using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-02-29&bucket=month")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var rows = await RowsAsync(response); Assert.Equal(2, rows.Count); Assert.All(rows, r => { Assert.Equal("t" + type.ToString(CultureInfo.InvariantCulture), r["series_id"]); Assert.Equal("Cost test", r["series_name"]); Assert.Equal("Cost", r["kind"]); Assert.Equal("EUR", r["unit"]); Assert.Equal("Priced", r["cost_status"]); Assert.Equal(r["value"], r["cost"]); }); Assert.Equal(30, Number(rows[0]["value"]), 6); Assert.Equal(24, Number(rows[0]["comparison_value"]), 6); Assert.Equal(15, Number(rows[1]["value"]), 6); Assert.Equal(12, Number(rows[1]["comparison_value"]), 6); // Without a comparison the column stays empty. using var plain = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-02-29&bucket=month&compare=none")); Assert.All(await RowsAsync(plain), r => Assert.Equal(string.Empty, r["comparison_value"])); } [Fact] public async Task A_month_with_nothing_booked_is_exported_as_no_data_not_as_an_available_priced_blank() { // §4.3 "one meaning across tables and exports": a cost bucket with nothing to bill is unknown, so its row never // pairs an empty value with "Available" and "Priced". A priced month keeps its figures. await using var box = new CostSandbox(fx); var category = await box.CategoryAsync($"export-{Guid.NewGuid():N}", 97); await box.ManualCostAsync(D(2024, 1, 10), 30, categoryId: category); using var app = new FrozenApp(fx.ConnectionString); using var response = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=category&id={category}&from=2024-01-01&to=2024-03-31&bucket=month&compare=none")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var rows = await RowsAsync(response); Assert.Equal(3, rows.Count); Assert.Equal(30, Number(rows[0]["value"]), 6); Assert.Equal("Available", rows[0]["status"]); foreach (var row in rows.Skip(1)) { Assert.Equal(string.Empty, row["value"]); Assert.NotEqual("Available", row["status"]); } } [Fact] public async Task A_type_exports_its_measures_and_a_selection_each_meter() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var first = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); var second = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10, 20); using var app = new FrozenApp(fx.ConnectionString); var range = "&from=2024-01-01&to=2024-02-29&bucket=month&compare=none"; using var measures = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=type&id={type}&metric=consumption{range}")); var use = Assert.Single((await RowsAsync(measures)).GroupBy(r => r["series_id"])); Assert.StartsWith($"t{type}:use:", use.Key, StringComparison.Ordinal); Assert.Equal([110d, 70d], use.Select(r => Number(r["value"]))); // A measure has no cost of its own: the cost columns stay empty rather than read as zero. Assert.All(use, r => Assert.Equal(string.Empty, r["cost_status"])); using var selection = await app.Client.GetAsync(Url($"/export/analysis.csv?scope=meters&ids={first},{second}{range}")); var bySeries = (await RowsAsync(selection)).GroupBy(r => r["series_id"]).ToDictionary(g => g.Key, g => g.Select(r => Number(r["value"])).ToList()); Assert.Equal([100d, 50d], bySeries["m" + first.ToString(CultureInfo.InvariantCulture)]); Assert.Equal([10d, 20d], bySeries["m" + second.ToString(CultureInfo.InvariantCulture)]); } [Fact] public async Task Anything_it_cannot_answer_is_a_400_never_a_500() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); using var app = new FrozenApp(fx.ConnectionString); foreach (var query in new[] { "?period=forever", "?bucket=hourly", "?from=2024-02-01&to=2024-01-01", "?scope=meters&ids=1,2,3,4,5,6,7", "?scope=meter", "?scope=category&id=1&metric=consumption", "?metric=balance", "?scope=meter&id=2147483647", "?scope=type&id=32000", "?scope=category&id=2147483647&metric=cost", $"?scope=meter&id={meter}&from=1900-01-01&to=2299-12-31&bucket=day", }) { using var response = await app.Client.GetAsync(Url(AnalysisLinks.ExportPath + query)); Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"{query} gave {(int)response.StatusCode}"); Assert.False(string.IsNullOrWhiteSpace(await response.Content.ReadAsStringAsync()), query); } // The refusal says what would work. using var tooFine = await app.Client.GetAsync(Url($"{AnalysisLinks.ExportPath}?scope=meter&id={meter}&from=1900-01-01&to=2299-12-31&bucket=day")); Assert.Contains("bucket=", await tooFine.Content.ReadAsStringAsync(), StringComparison.Ordinal); } [Fact] public async Task The_page_defaults_export_without_any_key() { using var app = new FrozenApp(fx.ConnectionString); using var response = await app.Client.GetAsync(Url(AnalysisLinks.ExportPath)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var text = (await response.Content.ReadAsStringAsync()).TrimStart(''); Assert.StartsWith(string.Join(',', AnalysisCsvWriter.Columns), text, StringComparison.Ordinal); } private static Uri Url(string relative) => new(relative, UriKind.Relative); private static double Number(string cell) => double.Parse(cell, NumberStyles.Float, CultureInfo.InvariantCulture); /// The data rows as column → cell (no quoted commas occur in these fixtures). private static async Task>> RowsAsync(HttpResponseMessage response) { var text = (await response.Content.ReadAsStringAsync()).TrimStart(''); var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); var header = lines[0].Split(','); Assert.Equal(AnalysisCsvWriter.Columns, header); return [ .. lines.Skip(1).Select(line => { var cells = line.Split(','); Assert.Equal(header.Length, cells.Length); return header.Zip(cells).ToDictionary(p => p.First, p => p.Second, StringComparer.Ordinal); }), ]; } /// The app on the frozen clock of ; disposes all it created. private sealed class FrozenApp : IDisposable { private readonly MeterVaultAppFactory _root; private readonly WebApplicationFactory _app; public FrozenApp(string connectionString) { _root = new MeterVaultAppFactory(connectionString); _app = _root.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => services.AddSingleton(new FixedTimeProvider(Now)))); Client = _app.CreateClient(); } public HttpClient Client { get; } public void Dispose() { Client.Dispose(); _app.Dispose(); _root.Dispose(); } } }