Files
MeterVault/tests/Integration.Tests/ApiTests.cs
T
schmidt.florian 62d102c335
ci / build-test (push) Successful in 1m13s
Ingestion: derive consumption on ingest, and poll HA in minutes not seconds
Live ingestion wrote the raw reading and stopped there. Import, the REST push
endpoint and the meter editor all recompute afterwards; the MQTT/Tasmota/HA
path was the one that did not, so a polled reading landed in `reading` and
every derived figure stayed frozen at the last import. Observed on a
GenerationCounter: 45 readings, 44 consumption rows, generation pinned to the
register value of the last imported reading.

Recompute inline rather than behind a debounce. Normalizing a whole meter is
cheap at metering cadence and a background dirty-set worker is machinery this
does not yet need; the remark on RenormalizeAsync records when it would.

Fixes a latent bug this surfaced in NormalizationService: ExecuteDelete drops
the consumption rows in the database but leaves them in the change tracker, so
a second recompute on the same context threw an identity conflict on
(meter, time, kind). One worker scope ingesting two readings was enough to hit
it. Detach the stale entries after the delete.

Poll interval is now minutes, default 60, replacing seconds/60. A meter answers
"how much this month, what will it cost" — an hourly sample answers that
exactly as well as a per-second one, with far less raw volume (SDD §5.5). The
`pollSeconds` key no longer binds, so existing sources fall back to the 60
default and move from every-60-seconds to hourly, which is the intent. A source
that had deliberately set e.g. 300 seconds also lands on 60 minutes.

Two test cleanups now delete consumption before the meter: live ingestion never
produced any before, so the FK had nothing to trip on.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 18:45:57 +02:00

104 lines
4.2 KiB
C#

using System.Net;
using System.Net.Http.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
[Collection("Timescale")]
public sealed class ApiTests(TimescaleFixture fx)
{
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
[Fact]
public async Task Readings_push_requires_key_and_writes_when_authorized()
{
int meterId;
await using (var db = fx.CreateContext())
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter { Name = $"api-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
meterId = meter.Id;
}
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
var push = new[] { new ReadingPush(meterId, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 1500) };
// Without the key → 401.
var unauthorized = await client.PostAsJsonAsync("/api/v1/readings", push);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
// With the key → 200 and the reading is persisted.
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/readings")
{
Content = JsonContent.Create(push),
};
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var authorized = await client.SendAsync(request);
authorized.EnsureSuccessStatusCode();
await using (var db = fx.CreateContext())
{
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId);
Assert.Equal(1500, reading.Value, 3);
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}
private sealed record EventPush(int MeterId, DateTimeOffset Time, string Type,
double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
[Fact]
public async Task Api_is_closed_when_no_keys_are_configured()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: false);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/readings",
new[] { new ReadingPush(1, DateTimeOffset.UtcNow, 1) });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Events_for_a_missing_meter_return_404_not_500()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/events")
{
Content = JsonContent.Create(new EventPush(999999, DateTimeOffset.UtcNow, "Delivery", 100, null, null, "L", null)),
};
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Meters_endpoint_and_swagger_are_available()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var metersRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/meters");
metersRequest.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var meters = await client.SendAsync(metersRequest);
meters.EnsureSuccessStatusCode();
// Swagger document is served (no API key required).
var swagger = await client.GetAsync(new Uri("/swagger/v1/swagger.json", UriKind.Relative));
swagger.EnsureSuccessStatusCode();
Assert.Contains("MeterVault API", await swagger.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
}