using System.Net; using System.Text.Json; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; using MeterVault.Integration.Tests.Costing; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests; /// /// Pins the JSON shape of the read endpoints other systems consume (brief §9.9, note D-45). The analysis /// rework reroutes what feeds them; these contracts are what must not move: every existing field keeps /// its name and JSON type, and new information arrives only as additional fields — the quantity's status, /// kind and unit, the cost's status and missing prices, the summary's percentage applicability and latest month. /// /// /// The summary covers the whole instance, so every test starts from — and leaves — an instance without meters, /// tariffs or manual costs (like ). The app runs on the clock of /// (19 September 2026, 14:37 Berlin). /// [Collection("Timescale")] public sealed class ApiContractTests(TimescaleFixture fx) : IAsyncLifetime { public async Task InitializeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await ClearDataAsync(db); } [Fact] public async Task Consumption_and_cost_keep_their_fields_and_types() { var meterId = await CreateMeterWithTwoMonthsAsync(); try { using var factory = new MeterVaultAppFactory(fx.ConnectionString); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); using var consumption = await GetJsonAsync(client, $"/api/v1/consumption?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z"); var rows = consumption.RootElement.EnumerateArray().ToList(); Assert.Equal(2, rows.Count); foreach (var row in rows) { AssertProperty(row, "period", JsonValueKind.String); AssertProperty(row, "consumption", JsonValueKind.Number); AssertProperty(row, "generation", JsonValueKind.Number); // Added (D-45): whether the quantity can be trusted, and what it is in. AssertString(row, "status", "Available"); AssertString(row, "issue", "None"); AssertString(row, "kind", "Consumption"); AssertString(row, "unit", "kWh"); } Assert.Equal("2024-01-01", rows[0].GetProperty("period").GetString()); Assert.Equal(100, rows[0].GetProperty("consumption").GetDouble(), 6); Assert.Equal(50, rows[1].GetProperty("consumption").GetDouble(), 6); using var cost = await GetJsonAsync(client, $"/api/v1/cost?meter={meterId}&from=2024-01-01T00:00:00Z&to=2024-03-01T00:00:00Z"); var costRows = cost.RootElement.EnumerateArray().ToList(); Assert.Equal(2, costRows.Count); foreach (var row in costRows) { AssertProperty(row, "period", JsonValueKind.String); AssertProperty(row, "consumption", JsonValueKind.Number); AssertProperty(row, "generation", JsonValueKind.Number); AssertProperty(row, "cost", JsonValueKind.Number); // Added (D-45): the price coverage of the cost. AssertString(row, "costStatus", "Priced"); AssertProperty(row, "missingPrices", JsonValueKind.Array); Assert.Empty(row.GetProperty("missingPrices").EnumerateArray()); AssertString(row, "status", "Available"); } Assert.Equal(30, costRows[0].GetProperty("cost").GetDouble(), 6); Assert.Equal(15, costRows[1].GetProperty("cost").GetDouble(), 6); } finally { await CleanupAsync(meterId); } } [Fact] public async Task Bounds_with_an_offset_are_accepted() { // A caller in Berlin sends its local midnight. Npgsql only accepts UTC instants for timestamptz, // so the endpoint must convert rather than fail with a server error. var meterId = await CreateMeterWithTwoMonthsAsync(); try { using var factory = new MeterVaultAppFactory(fx.ConnectionString); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); var from = Uri.EscapeDataString("2024-01-01T00:00:00+01:00"); var to = Uri.EscapeDataString("2024-03-01T00:00:00+01:00"); using var response = await client.GetAsync(new Uri($"/api/v1/cost?meter={meterId}&from={from}&to={to}", UriKind.Relative)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } finally { await CleanupAsync(meterId); } } [Fact] public async Task A_missing_price_leaves_cost_at_zero_and_says_why() { // D-38 on the API: cost stays a number, 0 when nothing could be priced, and the new fields say why — a gap in a // priced history (January, before the meter's own price starts) or no tariff at all. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var gap = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); await box.MeterPriceAsync(gap, 0.30, D(2024, 2, 1)); var unpricedType = await box.TypeAsync(); var unpriced = await box.MonthlyAsync(unpricedType, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); using var api = new FrozenApi(fx.ConnectionString); var client = api.Client; var rows = (await GetJsonAsync(client, CostUrl(gap))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, rows.Count); var january = rows[0]; Assert.Equal(0, january.GetProperty("cost").GetDouble()); Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6); AssertString(january, "costStatus", "PriceGap"); var missing = Assert.Single(january.GetProperty("missingPrices").EnumerateArray().ToList()); AssertString(missing, "component", "UnitPrice"); AssertString(missing, "reason", "PriceGap"); AssertString(missing, "scope", "Meter"); Assert.Equal(gap, missing.GetProperty("scopeId").GetInt32()); Assert.Equal(gap, missing.GetProperty("meterId").GetInt32()); AssertString(missing, "firstMonth", "2024-01-01"); AssertString(missing, "lastMonth", "2024-01-01"); Assert.Equal(JsonValueKind.False, missing.GetProperty("isCredit").ValueKind); var february = rows[1]; Assert.Equal(15, february.GetProperty("cost").GetDouble(), 6); AssertString(february, "costStatus", "Priced"); var notPriced = (await GetJsonAsync(client, CostUrl(unpriced))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, notPriced.Count); Assert.All(notPriced, row => { Assert.Equal(0, row.GetProperty("cost").GetDouble()); AssertString(row, "costStatus", "NotPriced"); AssertString(Assert.Single(row.GetProperty("missingPrices").EnumerateArray().ToList()), "reason", "NotPriced"); }); } [Fact] public async Task A_month_with_only_a_cost_is_a_cost_row_but_no_consumption_row() { // A meter's own standing charge accrues through a reading gap (D-40): February costs 3 € although nothing was // read. /cost reports it with the quantity's status; /consumption, which lists quantities, leaves it out. 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); await box.MeterPriceAsync(meter, 0.30, D(2024, 1, 1)); await box.TariffAsync(TariffScope.Meter, meter, TariffComponent.BasePrice, 3, "EUR/month", D(2024, 1, 1)); using var api = new FrozenApi(fx.ConnectionString); var costs = (await GetJsonAsync(api.Client, CostUrl(meter))).RootElement.EnumerateArray().ToList(); Assert.Equal([33d, 3d], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6))); AssertString(costs[1], "status", "Missing"); AssertString(costs[1], "costStatus", "Priced"); var consumption = (await GetJsonAsync(api.Client, ConsumptionUrl(meter))).RootElement.EnumerateArray().ToList(); var january = Assert.Single(consumption); AssertString(january, "period", "2024-01-01"); Assert.Equal(100, january.GetProperty("consumption").GetDouble(), 6); } [Fact] public async Task A_virtual_meter_returns_evaluated_values_with_a_status() { // D-45: a virtual meter used to return nothing; it now returns its formula evaluated month by month, with the // same status semantics as any meter — and its cost by its rule (here its sources' own costs, D-39). await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 30); var januaryOnly = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 10); await box.MeterPriceAsync(a, 0.30, D(2024, 1, 1)); await box.MeterPriceAsync(b, 0.10, D(2024, 1, 1)); var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); // No cost rule, so nothing but its sources' data can keep a month in the answer. var partial = await box.VirtualAsync(type, $"m{a} + m{januaryOnly}", QuantityKind.Consumption, "kWh", VirtualCostRule.None); var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); using var api = new FrozenApi(fx.ConnectionString); var client = api.Client; var rows = (await GetJsonAsync(client, ConsumptionUrl(sum))).RootElement.EnumerateArray().ToList(); Assert.Equal([120d, 80d], rows.Select(r => Math.Round(r.GetProperty("consumption").GetDouble(), 6))); Assert.All(rows, r => AssertString(r, "status", "Available")); Assert.All(rows, r => AssertString(r, "kind", "Consumption")); var costs = (await GetJsonAsync(client, CostUrl(sum))).RootElement.EnumerateArray().ToList(); Assert.Equal([(100 * 0.30) + (20 * 0.10), (50 * 0.30) + (30 * 0.10)], costs.Select(r => Math.Round(r.GetProperty("cost").GetDouble(), 6))); // A source without February makes February unknown, never "a + 0" (strict, D-27) — reported, not left out. var strict = (await GetJsonAsync(client, ConsumptionUrl(partial))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, strict.Count); Assert.Equal(110, strict[0].GetProperty("consumption").GetDouble(), 6); AssertString(strict[1], "status", "Missing"); AssertString(strict[1], "issue", "MissingSource"); Assert.Equal(0, strict[1].GetProperty("consumption").GetDouble()); // A definition that cannot be evaluated says so in every month. var invalid = (await GetJsonAsync(client, ConsumptionUrl(broken))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, invalid.Count); Assert.All(invalid, r => AssertString(r, "status", "Invalid")); Assert.All(invalid, r => AssertString(r, "issue", "InvalidDefinition")); } [Fact] public async Task A_meter_that_is_not_costed_or_cannot_be_evaluated_never_reads_as_a_priced_zero() { // A-16: costAvailability exists so the API never turns an unknown cost into a priced 0. A dependency loop, a // ratio that divides by zero and a generation meter have no cost — each says so (costStatus, costAvailability and // the additive costRule/notCosted), while cost itself stays the number 0 (D-45). await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 100, 50); var b = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), 20, 0); var pv = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2024, 1, 1), 250, 200); await box.TypePriceAsync(type, 0.30, D(2023, 1, 1)); var one = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity); var two = await box.VirtualAsync(type, $"m{one}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity); await using (var db = fx.CreateContext()) { var meter = await db.Meters.FindAsync(one); meter!.Meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{a} + m{two}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity)); await db.SaveChangesAsync(); } var ratio = await box.VirtualAsync(type, $"m{a} / m{b}", QuantityKind.Indicator, "kWh/kWh", VirtualCostRule.None); using var api = new FrozenApi(fx.ConnectionString); var client = api.Client; // The loop: every month is invalid, and so is its cost — never "Priced, Available". var loop = (await GetJsonAsync(client, CostUrl(one))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, loop.Count); Assert.All(loop, row => { AssertString(row, "status", "Invalid"); Assert.Equal(0, row.GetProperty("cost").GetDouble()); AssertString(row, "costStatus", "NotPriced"); AssertString(row, "costAvailability", "Invalid"); AssertString(row, "costRule", "None"); AssertString(row, "notCosted", "NotEvaluable"); }); // The ratio is never costed (D-26); February divides by zero and is invalid. var indicator = (await GetJsonAsync(client, CostUrl(ratio))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, indicator.Count); Assert.All(indicator, row => AssertString(row, "costStatus", "NotPriced")); AssertString(indicator[1], "status", "Invalid"); AssertString(indicator[1], "costAvailability", "Invalid"); AssertString(indicator[0], "notCosted", "NoCostRule"); // Generation is never billed (D-34): a known quantity, and no cost — not a priced one. var generation = (await GetJsonAsync(client, CostUrl(pv))).RootElement.EnumerateArray().ToList(); Assert.Equal(2, generation.Count); Assert.All(generation, row => { AssertString(row, "costStatus", "NotPriced"); AssertString(row, "costAvailability", "Available"); AssertString(row, "costRule", "None"); AssertString(row, "notCosted", "Generation"); }); // A priced meter keeps its rule and names no reason. var priced = (await GetJsonAsync(client, CostUrl(a))).RootElement.EnumerateArray().ToList(); Assert.All(priced, row => { AssertString(row, "costStatus", "Priced"); AssertString(row, "notCosted", "None"); Assert.NotEqual("None", row.GetProperty("costRule").GetString()); }); } [Fact] public async Task Dashboard_summary_keeps_its_fields_and_types() { using var factory = new MeterVaultAppFactory(fx.ConnectionString); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); using var summary = await GetJsonAsync(client, "/api/v1/dashboard/summary"); var root = summary.RootElement; AssertProperty(root, "asOf", JsonValueKind.String); AssertProperty(root, "latestMonthCost", JsonValueKind.Number); foreach (var name in new[] { "month", "year" }) { var kpi = root.GetProperty(name); AssertProperty(kpi, "current", JsonValueKind.Number); AssertProperty(kpi, "previous", JsonValueKind.Number); AssertProperty(kpi, "delta", JsonValueKind.Number); AssertProperty(kpi, "deltaPercent", JsonValueKind.Number); AssertProperty(kpi, "direction", JsonValueKind.Number); // Added (D-45): whether the percentage means anything. Against a zero baseline it does not. AssertProperty(kpi, "deltaPercentApplicable", JsonValueKind.False); } // Added (D-45): the month the latest cost is for — none on an empty instance. AssertProperty(root, "latestMonth", JsonValueKind.Null); } [Fact] public async Task The_summary_follows_the_bill_and_names_its_latest_month() { // The legacy calendar windows (this month and year to now, against the whole previous ones), priced as the bill: // the grid import is billed, the household meter behind it is not (D-34), and a manual cost counts once. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var grid = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); var house = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2025, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); await box.MonthlyReadingsAsync(grid, D(2025, 1, 1), [.. Enumerable.Repeat(100d, 19), 0]); // 2025: 1,200; 2026 to July: 700; August: 0 await box.ReadingsAsync(grid, (Midnight(2026, 9, 10), 1950)); // September to date: 50 await box.MonthlyReadingsAsync(house, D(2025, 1, 1), [.. Enumerable.Repeat(300d, 12)]); await box.TypePriceAsync(type, 0.10, D(2025, 1, 1)); await box.ManualCostAsync(D(2026, 9, 5), 7); using var api = new FrozenApi(fx.ConnectionString); var client = api.Client; var root = (await GetJsonAsync(client, "/api/v1/dashboard/summary")).RootElement; AssertString(root, "asOf", "2026-09-19"); var month = root.GetProperty("month"); Assert.Equal(12, month.GetProperty("current").GetDouble(), 6); // 50 × 0.10 + 7 Assert.Equal(0, month.GetProperty("previous").GetDouble(), 6); // August: a priced zero Assert.Equal(0, month.GetProperty("deltaPercent").GetDouble()); Assert.Equal(JsonValueKind.False, month.GetProperty("deltaPercentApplicable").ValueKind); Assert.Equal(1, month.GetProperty("direction").GetInt32()); var year = root.GetProperty("year"); Assert.Equal(82, year.GetProperty("current").GetDouble(), 6); // 750 × 0.10 + 7 Assert.Equal(120, year.GetProperty("previous").GetDouble(), 6); // the grid's 1,200 kWh; not the house's 3,600 Assert.Equal(-38, year.GetProperty("delta").GetDouble(), 6); Assert.Equal(-38d / 120 * 100, year.GetProperty("deltaPercent").GetDouble(), 6); Assert.Equal(JsonValueKind.True, year.GetProperty("deltaPercentApplicable").ValueKind); Assert.Equal(-1, year.GetProperty("direction").GetInt32()); // The latest month with data rests on the grid meter and the manual cost alike (D-19). Assert.Equal(12, root.GetProperty("latestMonthCost").GetDouble(), 6); var latest = root.GetProperty("latestMonth"); AssertString(latest, "period", "2026-09-01"); AssertString(latest, "basis", "Both"); } private static string ConsumptionUrl(int meter) => Url("consumption", meter); private static string CostUrl(int meter) => Url("cost", meter); /// January and February 2024 by Berlin's local midnights, so there is no partial edge hour. private static string Url(string endpoint, int meter) => $"/api/v1/{endpoint}?meter={meter}&from={Uri.EscapeDataString("2024-01-01T00:00:00+01:00")}&to={Uri.EscapeDataString("2024-03-01T00:00:00+01:00")}"; private static void AssertProperty(JsonElement element, string name, JsonValueKind kind) { Assert.True(element.TryGetProperty(name, out var value), $"missing property '{name}' in {element}"); Assert.Equal(kind, value.ValueKind); } private static void AssertString(JsonElement element, string name, string expected) { AssertProperty(element, name, JsonValueKind.String); Assert.Equal(expected, element.GetProperty(name).GetString()); } private static async Task GetJsonAsync(HttpClient client, string url) { using var response = await client.GetAsync(new Uri(url, UriKind.Relative)); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } /// A kWh meter with imported month rows for January (100) and February (150) and a 0.30 price on the meter. private async Task CreateMeterWithTwoMonthsAsync() { await using var db = fx.CreateContext(); await DatabaseSeeder.SeedAsync(db); var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); var meter = new Meter { Name = $"contract-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; db.Meters.Add(meter); await db.SaveChangesAsync(); db.Readings.AddRange( new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = 100, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel }, new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero), Value = 150, Quality = ReadingQuality.Imported, Flags = ReadingFlags.MonthLabel }); db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice, Value = 0.30, Unit = "EUR/kWh", ValidFrom = new DateOnly(2023, 1, 1), }); await db.SaveChangesAsync(); await using var tx = await db.Database.BeginTransactionAsync(); var normalization = new NormalizationService(db, Core.Normalization.NormalizationEngine.CreateDefault(), Microsoft.Extensions.Options.Options.Create(new Infrastructure.Options.MeterVaultOptions { TimeZone = "Europe/Berlin" })); await normalization.RecomputeMeterAsync(meter.Id, null); await db.SaveChangesAsync(); await tx.CommitAsync(); return meter.Id; } private async Task CleanupAsync(int meterId) { await using var db = fx.CreateContext(); await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter && t.ScopeId == meterId).ExecuteDeleteAsync(); await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync(); await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync(); } private static async Task ClearDataAsync(MeterVaultDbContext db) { await db.MeterLinks.ExecuteDeleteAsync(); await db.Consumption.ExecuteDeleteAsync(); await db.Readings.ExecuteDeleteAsync(); await db.MeterEvents.ExecuteDeleteAsync(); await db.ManualCosts.ExecuteDeleteAsync(); await db.CostCategoryMembers.ExecuteDeleteAsync(); await db.Tariffs.ExecuteDeleteAsync(); await db.Tanks.ExecuteDeleteAsync(); await db.MeterSources.ExecuteDeleteAsync(); await db.Meters.ExecuteDeleteAsync(); await db.ImportBatches.ExecuteDeleteAsync(); } /// The app on the frozen clock of , with an API key; disposes all it created. private sealed class FrozenApi : IDisposable { private readonly MeterVaultAppFactory _root; private readonly WebApplicationFactory _app; public FrozenApi(string connectionString) { _root = new MeterVaultAppFactory(connectionString); _app = _root.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => services.AddSingleton(new FixedTimeProvider(Now)))); Client = _app.CreateClient(); Client.DefaultRequestHeaders.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); } public HttpClient Client { get; } public void Dispose() { Client.Dispose(); _app.Dispose(); _root.Dispose(); } } }