Files
MeterVault/tests/Integration.Tests/MeterPeriodServiceTests.cs
T
schmidt.florian cedd60ab45
ci / build-test (push) Successful in 1m17s
Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
Three defects found reviewing the last few commits.

Deriving consumption on ingest made the batch reading endpoint quadratic. A
recompute rewrites a meter's entire consumption series, and POST
/api/v1/readings ran one per reading -- 500 readings for one meter meant 500
full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint
normalizes each touched meter once after the batch.

Percentage change divided by a possibly negative baseline. A net-export meter
going from -100 to -150 exported half again as much and would have been
reported as "+50%", reading as more consumption. A non-positive baseline now
reports no basis rather than a confident lie.

The data-protection key ring had no persistent home outside Docker Compose. The
LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would
otherwise create it under the default umask, leaving a key ring world-readable
-- and the Unraid template maps it, since without that every UI-entered secret
was lost whenever the container was recreated. README documents the variable
and the trust boundary: keys on disk protect against leaked database content,
not against an attacker who already has the host.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:56 +02:00

146 lines
5.4 KiB
C#

using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The meter-detail headline numbers: month/year totals bucketed in the instance timezone, and the
/// generation-vs-consumption split that decides which of the two a meter reports.
/// </summary>
[Collection("Timescale")]
public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Buckets_by_local_month_and_compares_with_the_previous_one()
{
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var thisMonth = new DateOnly(today.Year, today.Month, 1);
var lastMonth = thisMonth.AddMonths(-1);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), 30);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, lastMonth.AddDays(3), 100);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal("Consumption", view!.Label);
Assert.Equal(30d, view.MonthToDate, 3);
Assert.Equal(100d, view.LastMonth, 3);
Assert.Equal(130d, view.YearToDate, 3);
// Projection scales the partial month up, so it must be at least what has already happened.
Assert.True(view.MonthProjected >= view.MonthToDate);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_generation_counter_reports_generation_not_consumption()
{
// Regression: a PV meter showed "0 kWh consumption", which is true and useless.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.GenerationCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
await AddConsumptionAsync(
db, meterId, ConsumptionKind.Generation, new DateOnly(today.Year, today.Month, 1).AddDays(1), 42);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal("Generation", view!.Label);
Assert.Equal(42d, view.MonthToDate, 3);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_meter_with_no_consumption_yields_an_empty_history_rather_than_a_flat_line()
{
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.False(view!.HasHistory);
Assert.Empty(view.Last12Months);
Assert.Null(view.MonthChange); // no previous month to divide by
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_negative_previous_period_reports_no_basis_rather_than_an_inverted_percentage()
{
// Net export: -100 -> -150 is half again as much exported, but dividing by a negative
// baseline would render it "+50%", which reads as more consumption.
await using var db = fx.CreateContext();
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var thisMonth = new DateOnly(today.Year, today.Month, 1);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), -150);
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddMonths(-1).AddDays(3), -100);
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Null(view!.MonthChange);
await CleanupAsync(db, meterId);
}
private MeterPeriodService NewService()
{
var options = Microsoft.Extensions.Options.Options.Create(
new MeterVaultOptions { TimeZone = "Europe/Berlin", Currency = "EUR" });
return new MeterPeriodService(fx, new CostService(fx), options);
}
private static async Task<int> SetupAsync(MeterVaultDbContext db, MeterMode mode)
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter
{
Name = $"period-{Guid.NewGuid():N}",
EnergyTypeId = type.Id,
Mode = mode,
Unit = "kWh",
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter.Id;
}
private static async Task AddConsumptionAsync(
MeterVaultDbContext db, int meterId, ConsumptionKind kind, DateOnly day, double amount)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
// Midday local, so the row cannot drift into an adjacent month through the UTC offset.
Time = new DateTimeOffset(day.Year, day.Month, day.Day, 12, 0, 0, TimeSpan.Zero),
Kind = kind,
Amount = amount,
Quality = ReadingQuality.Measured,
});
await db.SaveChangesAsync();
}
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
{
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}