c0bbaba99f
ci / build-test (push) Successful in 1m8s
MqttMessageRouter matched purely on topic with no endpoint predicate, and RouteAsync was not even passed an endpoint id. Topic filters routinely overlap between brokers — every Tasmota install publishes tele/+/SENSOR — so with two brokers a message on A was ingested by a source bound to B. HA enforced the binding on both workers; MQTT enforced it only at subscribe time. Pass the endpoint id through: MQTTnet's event args carry the topic but not the delivering connection, so CreateClient captures the id in the handler closure. ResolveTopicsAsync drops its `|| EndpointId == null` clause to match, since an unbound source is no longer routed and subscribing its topic everywhere would only invite traffic nothing consumes. That last part would silently kill unbound sources that work today, so a data migration binds them to the single broker when exactly one exists — the case where old and new behaviour coincide. Two or more brokers is left alone: the old behaviour was already ambiguous and a guess could route a meter's data to the wrong broker. HA sources are excluded; they have always required an endpoint, so binding them would activate ingestion never previously running. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
205 lines
7.8 KiB
C#
205 lines
7.8 KiB
C#
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 brokerId = await CreateBrokerAsync(db);
|
||
var (meterId, _) = await SetupAsync(
|
||
db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR", endpointId: brokerId);
|
||
var router = new MqttMessageRouter(db, new IngestionService(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, new IngestionService(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);
|
||
}
|
||
|
||
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.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||
await db.IngestionEndpoints.Where(e => e.Name.StartsWith("broker-")).ExecuteDeleteAsync();
|
||
}
|
||
}
|