Files
MeterVault/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs
T
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

157 lines
5.9 KiB
C#
Raw 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 System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests.Ingestion;
[Collection("Timescale")]
public sealed class IngestionServiceTests(TimescaleFixture fx)
{
private static readonly DateTimeOffset T0 = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Writes_and_updates_idempotently_with_scale_and_offset()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter, scale: 0.001, offset: 0);
var service = new IngestionService(db);
// 1000 raw × 0.001 = 1.0.
Assert.Equal(IngestionOutcome.Written, await service.IngestAsync(sourceId, T0, 1000));
Assert.Equal(IngestionOutcome.Updated, await service.IngestAsync(sourceId, T0, 2000)); // same time → update
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(2.0, reading.Value, 6);
var source = await db.MeterSources.SingleAsync(s => s.Id == sourceId);
Assert.Equal("ok", source.LastStatus);
Assert.Equal(2.0, source.LastValue!.Value, 6);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Rejects_spurious_decrease_on_a_cumulative_register()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
await service.IngestAsync(sourceId, T0, 500);
var outcome = await service.IngestAsync(sourceId, T0.AddHours(1), 400); // decrease, no event
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddHours(1)));
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Old_reset_does_not_permanently_disable_the_decrease_guard()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
// A reset early on explains an early decrease...
await service.IngestAsync(sourceId, T0, 100);
db.MeterEvents.Add(new MeterEvent { MeterId = meterId, Time = T0.AddMinutes(10), EventType = MeterEventType.CounterReset, NewValue = 0 });
await db.SaveChangesAsync();
Assert.Equal(IngestionOutcome.Written, await service.IngestAsync(sourceId, T0.AddMinutes(20), 30));
await service.IngestAsync(sourceId, T0.AddHours(1), 200);
// ...but a later spurious decrease with NO event in its window must still be rejected.
var outcome = await service.IngestAsync(sourceId, T0.AddHours(2), 150);
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Allows_decrease_when_a_swap_event_explains_it()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
await service.IngestAsync(sourceId, T0, 500);
db.MeterEvents.Add(new MeterEvent
{
MeterId = meterId,
Time = T0.AddMinutes(30),
EventType = MeterEventType.MeterSwap,
PrevValue = 500,
NewValue = 0,
});
await db.SaveChangesAsync();
var outcome = await service.IngestAsync(sourceId, T0.AddHours(1), 20); // new meter reads low
Assert.Equal(IngestionOutcome.Written, outcome);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Mqtt_router_ingests_a_tasmota_payload()
{
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR");
var router = new MqttMessageRouter(db, new IngestionService(db), NullLogger<MqttMessageRouter>.Instance);
var routed = await router.RouteAsync(
"tele/plug7/SENSOR",
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}""");
Assert.Equal(1, routed);
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId);
Assert.Equal(8421.0, reading.Value, 3);
Assert.Equal(new DateTimeOffset(2024, 3, 1, 10, 0, 0, TimeSpan.Zero), reading.Time);
await CleanupAsync(db, meterId);
}
private static async Task<(int MeterId, int SourceId)> SetupAsync(
MeterVaultDbContext db, MeterMode mode, double scale = 1, double offset = 0,
string topic = "tele/x/SENSOR", string? path = "ENERGY.Total")
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter
{
Name = $"ingest-{Guid.NewGuid():N}",
EnergyTypeId = type.Id,
Mode = mode,
Unit = "kWh",
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
var source = new MeterSource
{
MeterId = meter.Id,
SourceType = SourceType.Tasmota,
ValueKind = SourceValueKind.Register,
Scale = scale,
Offset = offset,
Config = JsonSerializer.Serialize(new { topic, path }),
};
db.MeterSources.Add(source);
await db.SaveChangesAsync();
return (meter.Id, source.Id);
}
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
{
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}