cedd60ab45
ci / build-test (push) Successful in 1m17s
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
146 lines
5.4 KiB
C#
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();
|
|
}
|
|
}
|