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