Files
MeterVault/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs
T
schmidt.florian af786c7b28
ci / build-test (push) Successful in 1m37s
Meters: add manual reading entry from the meter-detail Readings tab
Entering a reading by hand previously meant POST /api/v1/readings with an
API key, or a one-row CSV through the import wizard. SourceType.Manual
existed in the enum but nothing was behind it. This adds the click path,
built for the case it is actually used in: walking to each manual meter
with a phone in hand.

"Add reading" on the Readings tab opens a dialog prefilled with the
meter's last register value and the current local time, both editable:

- An on-screen keypad, because a register is read standing at the meter.
  It behaves like a calculator against the prefill - the first digit
  replaces it (a fresh register), while backspace edits it in place,
  which is the common case since only a register's last digits move.
- Typed input accepts both separators (last one wins), so a German and
  an English phone keyboard both do the right thing. ReadingEntry owns
  that rule and is unit-tested; it deliberately differs from
  GermanNumber, where a lone dot really is a thousands separator.
- A live parsed-value echo plus delta-since-last, which is the net that
  catches a mistyped digit before it is committed.
- Decrease / replaces-existing / future / backdated surfaced before
  saving, and DST spring-forward gaps refused rather than shifted.

The verdict line sits in a fixed-height, no-wrap slot above the keypad.
That is load-bearing, not cosmetic: an alert that appears there when the
value dips below the last reading moves the keys out from under the
user's thumb mid-entry, which is a guaranteed mistype on a phone. The
long-form explanation goes below the keypad, where reflow is harmless.

Saving goes through IngestionService.IngestByMeterAsync, so the
monotonic-decrease guard and inline renormalization apply exactly as for
any other ingest. A new optional quality parameter stamps the row
ReadingQuality.Manual; null preserves today's behaviour, so a source
re-reporting the same timestamp updates the value without silently
relabelling a hand-entered or imported reading.

Also: the meter-detail tabs now render times in the instance timezone
per SDD section 10, instead of raw UTC. Without it a reading entered at
18:00 reads back as 16:00. Side effect is that historic imported monthly
rows show 01:00/02:00 rather than 00:00 - correct, if noisier.

Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
2026-08-01 10:25:05 +02:00

324 lines
13 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 = NewIngestion(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 = NewIngestion(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 = NewIngestion(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 = NewIngestion(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 brokerId = await CreateBrokerAsync(db);
var (meterId, _) = await SetupAsync(
db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR", endpointId: brokerId);
var router = new MqttMessageRouter(db, NewIngestion(db), NullLogger<MqttMessageRouter>.Instance);
var routed = await router.RouteAsync(
brokerId,
"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);
}
[Fact]
public async Task Mqtt_router_ignores_a_source_bound_to_another_broker()
{
await using var db = fx.CreateContext();
var brokerA = await CreateBrokerAsync(db);
var brokerB = await CreateBrokerAsync(db);
// Topic filter that both brokers' traffic would match — the binding is the only thing
// separating them.
var (meterId, _) = await SetupAsync(
db, MeterMode.CumulativeCounter, topic: "tele/+/SENSOR", endpointId: brokerB);
var router = new MqttMessageRouter(db, NewIngestion(db), NullLogger<MqttMessageRouter>.Instance);
var routed = await router.RouteAsync(
brokerA,
"tele/plug7/SENSOR",
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}""");
Assert.Equal(0, routed);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId));
// Same message on the broker it is actually bound to does land.
Assert.Equal(1, await router.RouteAsync(
brokerB,
"tele/plug7/SENSOR",
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}"""));
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Ingesting_a_reading_derives_consumption_without_a_separate_recompute()
{
// Regression: live ingestion used to write only the raw reading, so consumption/generation
// stayed frozen at the last import until something else recomputed the meter.
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestAsync(sourceId, T0, 1000);
await service.IngestAsync(sourceId, T0.AddHours(1), 1250);
var consumption = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId)
.OrderBy(c => c.Time)
.ToListAsync();
// The first reading is anchored against the meter's baseline (0), so it contributes 1000;
// what proves the fix is the second reading's 250 delta being there at all.
Assert.Equal(2, consumption.Count);
Assert.Equal(250d, consumption.Single(c => c.Time == T0.AddHours(1)).Amount, 3);
Assert.Equal(1250d, consumption.Sum(c => c.Amount), 3);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_batch_can_defer_normalization_and_derive_the_same_series_once_at_the_end()
{
// Recomputing rewrites a meter's whole consumption series, so the batch endpoint skips it
// per reading and does it once. The result must be identical to normalizing as it goes.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
for (var hour = 0; hour < 5; hour++)
{
await service.IngestByMeterAsync(meterId, T0.AddHours(hour), 1000 + (hour * 10), renormalize: false);
}
Assert.False(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
await service.RenormalizeMeterAsync(meterId);
var consumption = await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).ToListAsync();
Assert.Equal(5, consumption.Count);
Assert.Equal(1040d, consumption.Sum(c => c.Amount), 3); // baseline 0 → 1000, then 4 × 10
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately()
{
// The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody
// walked to the meter to read is indistinguishable from one a sensor reported.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Written, written);
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(ReadingQuality.Manual, reading.Quality);
Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
// Correcting a typo re-enters the same timestamp: value replaced, still manual.
var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Updated, updated);
var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1100d, corrected.Value, 6);
Assert.Equal(ReadingQuality.Manual, corrected.Quality);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1200d, reading.Value, 6); // the newer value still wins...
Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other()
{
// The dialog warns before saving, but the guard is what actually protects the series: a
// mistyped register must not silently wipe out a month of consumption.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
var outcome = await service.IngestByMeterAsync(
meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30)));
await CleanupAsync(db, meterId);
}
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));
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", int? endpointId = null)
{
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,
EndpointId = endpointId ?? await CreateBrokerAsync(db),
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<int> CreateBrokerAsync(MeterVaultDbContext db)
{
var endpoint = new IngestionEndpoint
{
Type = EndpointType.MqttBroker,
Name = $"broker-{Guid.NewGuid():N}",
Config = """{"host":"localhost","port":1883}""",
};
db.IngestionEndpoints.Add(endpoint);
await db.SaveChangesAsync();
return endpoint.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.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
await db.IngestionEndpoints.Where(e => e.Name.StartsWith("broker-")).ExecuteDeleteAsync();
}
}