M6: REST API + API-key auth + OpenAPI
- Minimal API under /api/v1 (SDD §9): POST /readings (idempotent HA push), GET /meters, /energy-types, /consumption, /cost, /dashboard/summary, POST /events (records + recomputes), GET+POST /tariffs, GET /sources/status. - IngestionService.IngestByMeterAsync for direct REST push (batch-safe upsert via Local cache). - ApiKeyFilter: X-Api-Key enforced against configured keys (open only when none set). - ReverseProxyTrust middleware: adopt X-Forwarded-User/Remote-User behind Authelia/Traefik. - Swagger/OpenAPI (Swashbuckle) at /swagger. - Tests: push rejected without key (401), accepted + persisted with key; meters + swagger live. 94 tests green (56 Core + 38 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
|
using MeterVault.Infrastructure.Ingestion;
|
||||||
|
using MeterVault.Infrastructure.Normalization;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Api;
|
||||||
|
|
||||||
|
/// <summary>Request/response contracts for the REST API (SDD §9).</summary>
|
||||||
|
public sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
|
||||||
|
|
||||||
|
public sealed record EventPush(int MeterId, DateTimeOffset Time, MeterEventType Type,
|
||||||
|
double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
|
||||||
|
|
||||||
|
public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffComponent Component,
|
||||||
|
double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
|
||||||
|
|
||||||
|
public sealed record IngestResult(int Written, int Updated, int Rejected);
|
||||||
|
|
||||||
|
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
|
||||||
|
public static class ApiEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
||||||
|
|
||||||
|
api.MapPost("/readings", async (ReadingPush[] readings, IngestionService ingestion, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
int written = 0, updated = 0, rejected = 0;
|
||||||
|
foreach (var r in readings)
|
||||||
|
{
|
||||||
|
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
|
||||||
|
{
|
||||||
|
case IngestionOutcome.Written: written++; break;
|
||||||
|
case IngestionOutcome.Updated: updated++; break;
|
||||||
|
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new IngestResult(written, updated, rejected));
|
||||||
|
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
||||||
|
|
||||||
|
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
|
Results.Ok(await db.Meters.AsNoTracking()
|
||||||
|
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
||||||
|
.ToListAsync(ct)));
|
||||||
|
|
||||||
|
api.MapGet("/energy-types", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
|
Results.Ok(await db.EnergyTypes.AsNoTracking()
|
||||||
|
.Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
|
||||||
|
.ToListAsync(ct)));
|
||||||
|
|
||||||
|
api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
||||||
|
CostService cost, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct);
|
||||||
|
return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation }));
|
||||||
|
}).WithSummary("Normalized monthly consumption/generation for a meter.");
|
||||||
|
|
||||||
|
api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
|
||||||
|
CostService cost, CancellationToken ct) =>
|
||||||
|
Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct)));
|
||||||
|
|
||||||
|
api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) =>
|
||||||
|
Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct)));
|
||||||
|
|
||||||
|
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
|
||||||
|
NormalizationService normalization, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
db.MeterEvents.Add(new MeterEvent
|
||||||
|
{
|
||||||
|
MeterId = push.MeterId,
|
||||||
|
Time = push.Time,
|
||||||
|
EventType = push.Type,
|
||||||
|
Amount = push.Amount,
|
||||||
|
PrevValue = push.PrevValue,
|
||||||
|
NewValue = push.NewValue,
|
||||||
|
Unit = push.Unit,
|
||||||
|
Notes = push.Notes,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await normalization.RecomputeMeterAsync(push.MeterId, null, ct);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok();
|
||||||
|
}).WithSummary("Record a delivery / swap / tank level / correction and recompute the meter.");
|
||||||
|
|
||||||
|
api.MapGet("/tariffs", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
|
Results.Ok(await db.Tariffs.AsNoTracking().OrderBy(t => t.ValidFrom).ToListAsync(ct)));
|
||||||
|
|
||||||
|
api.MapPost("/tariffs", async (TariffPush push, MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var tariff = new Tariff
|
||||||
|
{
|
||||||
|
ScopeType = push.ScopeType,
|
||||||
|
ScopeId = push.ScopeId,
|
||||||
|
Component = push.Component,
|
||||||
|
Value = push.Value,
|
||||||
|
Unit = push.Unit,
|
||||||
|
ValidFrom = push.ValidFrom,
|
||||||
|
ValidTo = push.ValidTo,
|
||||||
|
};
|
||||||
|
db.Tariffs.Add(tariff);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Created($"/api/v1/tariffs/{tariff.Id}", new { tariff.Id });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/sources/status", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
|
Results.Ok(await db.MeterSources.AsNoTracking()
|
||||||
|
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
|
||||||
|
.ToListAsync(ct)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
|
||||||
|
/// When no keys are configured the API is open — intended only for local development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
|
||||||
|
{
|
||||||
|
public const string HeaderName = "X-Api-Key";
|
||||||
|
|
||||||
|
private readonly MeterVaultOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
|
{
|
||||||
|
if (_options.ApiKeys.Count == 0)
|
||||||
|
{
|
||||||
|
return await next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var provided = context.HttpContext.Request.Headers[HeaderName].ToString();
|
||||||
|
if (string.IsNullOrEmpty(provided) || !_options.ApiKeys.Contains(provided, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid or missing API key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When enabled (SDD §10), trusts an authenticating reverse proxy (Authelia/Traefik) by adopting
|
||||||
|
/// the user it asserts via <c>X-Forwarded-User</c> / <c>Remote-User</c>. This lets the homelab run
|
||||||
|
/// MeterVault behind existing SSO without built-in accounts. Only enable when the app is not
|
||||||
|
/// directly reachable — any client could otherwise spoof the header.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReverseProxyTrust
|
||||||
|
{
|
||||||
|
private static readonly string[] UserHeaders = ["X-Forwarded-User", "Remote-User", "X-Forwarded-Preferred-Username"];
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseReverseProxyTrust(this WebApplication app)
|
||||||
|
{
|
||||||
|
var options = app.Services.GetRequiredService<IOptions<MeterVaultOptions>>().Value;
|
||||||
|
if (!options.ReverseProxyTrust)
|
||||||
|
{
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
foreach (var header in UserHeaders)
|
||||||
|
{
|
||||||
|
var user = context.Request.Headers[header].ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(user))
|
||||||
|
{
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user)], "ReverseProxy");
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context).ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
<PackageReference Include="MudBlazor" />
|
<PackageReference Include="MudBlazor" />
|
||||||
<PackageReference Include="Blazor-ApexCharts" />
|
<PackageReference Include="Blazor-ApexCharts" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MeterVault.App.Api;
|
||||||
using MeterVault.App.Components;
|
using MeterVault.App.Components;
|
||||||
using MeterVault.Infrastructure;
|
using MeterVault.Infrastructure;
|
||||||
using MeterVault.Infrastructure.Options;
|
using MeterVault.Infrastructure.Options;
|
||||||
@@ -38,6 +39,10 @@ try
|
|||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" }));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
await MigrateDatabaseAsync(app).ConfigureAwait(false);
|
await MigrateDatabaseAsync(app).ConfigureAwait(false);
|
||||||
@@ -50,14 +55,20 @@ try
|
|||||||
|
|
||||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
app.UseReverseProxyTrust();
|
||||||
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
|
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
|
||||||
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
app.MapRazorComponents<App>()
|
app.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
app.MapMeterVaultApi();
|
||||||
|
|
||||||
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
||||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
|||||||
|
|
||||||
private readonly MeterVaultDbContext _db = db;
|
private readonly MeterVaultDbContext _db = db;
|
||||||
|
|
||||||
|
/// <summary>Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status.</summary>
|
||||||
public async Task<IngestionOutcome> IngestAsync(
|
public async Task<IngestionOutcome> IngestAsync(
|
||||||
int sourceId, DateTimeOffset time, double rawValue, CancellationToken cancellationToken = default)
|
int sourceId, DateTimeOffset time, double rawValue, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -53,10 +54,40 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
|||||||
return IngestionOutcome.RejectedDecrease;
|
return IngestionOutcome.RejectedDecrease;
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = await _db.Readings
|
var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false);
|
||||||
.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
|
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
|
||||||
|
public async Task<IngestionOutcome> IngestByMeterAsync(
|
||||||
|
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var meter = await _db.Meters
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (meter is null)
|
||||||
|
{
|
||||||
|
return IngestionOutcome.UnknownSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
var utc = time.ToUniversalTime();
|
||||||
|
if (MonotonicModes.Contains(meter.Mode)
|
||||||
|
&& await IsSpuriousDecreaseAsync(meter.Id, utc, value, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return IngestionOutcome.RejectedDecrease;
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IngestionOutcome> UpsertAsync(
|
||||||
|
Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
|
||||||
|
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
IngestionOutcome outcome;
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
_db.Readings.Add(new Reading
|
_db.Readings.Add(new Reading
|
||||||
@@ -64,20 +95,15 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
|||||||
MeterId = meter.Id,
|
MeterId = meter.Id,
|
||||||
Time = utc,
|
Time = utc,
|
||||||
Value = value,
|
Value = value,
|
||||||
SourceId = source.Id,
|
SourceId = sourceId,
|
||||||
Quality = ReadingQuality.Measured,
|
Quality = ReadingQuality.Measured,
|
||||||
});
|
});
|
||||||
outcome = IngestionOutcome.Written;
|
return IngestionOutcome.Written;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.Value = value;
|
|
||||||
existing.SourceId = source.Id;
|
|
||||||
outcome = IngestionOutcome.Updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
|
existing.Value = value;
|
||||||
return outcome;
|
existing.SourceId = sourceId ?? existing.SourceId;
|
||||||
|
return IngestionOutcome.Updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> IsSpuriousDecreaseAsync(
|
private async Task<bool> IsSpuriousDecreaseAsync(
|
||||||
|
|||||||
@@ -24,4 +24,13 @@ public sealed class MeterVaultOptions
|
|||||||
|
|
||||||
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
||||||
public int RawRetentionDays { get; set; } = 1095;
|
public int RawRetentionDays { get; set; } = 1095;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
|
||||||
|
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
|
||||||
|
/// </summary>
|
||||||
|
public IList<string> ApiKeys { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>Honour <c>X-Forwarded-User</c>/<c>Remote-User</c> from a trusted reverse proxy (SDD §10).</summary>
|
||||||
|
public bool ReverseProxyTrust { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
|
||||||
|
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ namespace MeterVault.Integration.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory<Program>
|
public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
|
public const string ApiKey = "test-api-key";
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Testing");
|
builder.UseEnvironment("Testing");
|
||||||
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
||||||
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
||||||
builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
|
builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
|
||||||
|
builder.UseSetting("MeterVault:ApiKeys:0", ApiKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user