Files
schmidt.florian a6edec2b12
ci / build-test (push) Successful in 2m45s
Polish/audit: fix bugs found by 3 subsystem audits
Correctness/data:
- Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser
  columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€.
- Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains
  a decrease — an old historical reset no longer permanently disables the guard.
- Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored).
- Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf.

Concurrency/infra:
- Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use
  short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy.
- /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500)
  on unknown meter.
- MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally.
- Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/
  hypertable calls (re-run-safe after a mid-migration crash).
- HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore.

API/security:
- API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401
  (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in.
- Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON.

+4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests
green; Docker deploy re-verified healthy with the API fail-closed.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
2026-07-13 12:56:51 +02:00

160 lines
6.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport;
namespace MeterVault.Integration.Tests.Costing;
/// <summary>
/// Reconciles the cost engine against the Wasser sheet's Kosten column (consumption × €/m³) and
/// checks category rollups and continuous-aggregate refresh (SDD §7.5, §5.4).
/// </summary>
[Collection("Timescale")]
public sealed class CostReconciliationTests(TimescaleFixture fx)
{
private static readonly DateTimeOffset From = new(2022, 11, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset To = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Water_cost_matches_the_sheet()
{
await using var db = fx.CreateContext();
var meterId = await ImportWaterAsync(db);
db.Tariffs.Add(new Tariff
{
ScopeType = TariffScope.Meter,
ScopeId = meterId,
Component = TariffComponent.UnitPrice,
Value = 5.00,
Unit = "EUR/m3",
ValidFrom = new DateOnly(2022, 11, 1),
});
await db.SaveChangesAsync();
var costs = await new CostService(fx).GetMeterCostsAsync(meterId, From, To);
var computed = costs.ToDictionary(c => c.Period, c => c.Cost);
var oracle = OracleByMonth(ReadRows(Water), dateColumn: 0, valueColumn: 4, firstDataRow: 1); // Kosten
AssertReconciles(computed, oracle, tolerance: 0.02, "water cost", minMatches: 10);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Category_rollup_includes_meter_costs()
{
await using var db = fx.CreateContext();
var meterId = await ImportWaterAsync(db);
var wasserCategory = await db.CostCategories.FirstAsync(c => c.Name == "Wasser");
db.Tariffs.Add(new Tariff
{
ScopeType = TariffScope.Meter,
ScopeId = meterId,
Component = TariffComponent.UnitPrice,
Value = 5.00,
Unit = "EUR/m3",
ValidFrom = new DateOnly(2022, 11, 1),
});
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = wasserCategory.Id, MeterId = meterId });
await db.SaveChangesAsync();
var rollup = await new CostService(fx).GetCategoryCostsAsync(wasserCategory.Id, From, To);
var dec2022 = rollup.Single(r => r.Period == new DateOnly(2022, 12, 1));
Assert.Equal(70d, dec2022.Cost, 2); // Dez 2022: 14 m³ × 5,00 €
await db.CostCategoryMembers.Where(m => m.MeterId == meterId).ExecuteDeleteAsync();
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Monthly_continuous_aggregate_refreshes_and_matches_base()
{
await using var db = fx.CreateContext();
var meterId = await ImportWaterAsync(db);
// refresh_continuous_aggregate cannot run inside a transaction — use the raw connection.
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = "CALL refresh_continuous_aggregate('consumption_monthly', NULL, NULL);";
await cmd.ExecuteNonQueryAsync();
}
double aggregated;
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText =
"SELECT sum(amount) FROM consumption_monthly WHERE meter_id = @m " +
"AND (bucket AT TIME ZONE 'Europe/Berlin')::date = DATE '2022-12-01';";
var p = cmd.CreateParameter();
p.ParameterName = "m";
p.Value = meterId;
cmd.Parameters.Add(p);
aggregated = Convert.ToDouble(await cmd.ExecuteScalarAsync());
}
Assert.Equal(14d, aggregated, 1); // Dez 2022 consumption
await CleanupAsync(db, meterId);
}
private static async Task<int> ImportWaterAsync(MeterVaultDbContext db)
{
await DatabaseSeeder.SeedAsync(db);
var waterType = await db.EnergyTypes.FirstAsync(t => t.Key == "water");
var meter = new Meter
{
Name = $"cost-water-{Guid.NewGuid():N}",
EnergyTypeId = waterType.Id,
Mode = MeterMode.CumulativeCounter,
Unit = "m3",
InitialBaseline = 820,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
var profile = new MappingProfile
{
Name = "cost-water",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
DetectCumulativeSwaps = true,
Columns =
[
new ColumnMapping
{
Index = 1, Role = MappingRole.Reading, MeterId = meter.Id, Unit = "m3", SwapConsumptionColumn = 2,
},
],
};
StagedImport staged;
using (var reader = new StreamReader(FixturePath(Water)))
{
staged = new CsvImporter().Stage(profile, reader);
}
var service = new ImportService(db, new NormalizationService(db, NormalizationEngine.CreateDefault()));
await service.CommitAsync(staged, "Wasser.csv", null);
return meter.Id;
}
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
{
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
await db.Tariffs.Where(t => t.ScopeId == meterId && t.ScopeType == TariffScope.Meter).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}