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();
}
}
}