Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -46,4 +46,16 @@ public sealed class MeterMetaTests
|
||||
|
||||
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void SetRole_removes_role_when_blank_and_keeps_other_keys(string? role)
|
||||
{
|
||||
var updated = MeterMeta.SetRole("{\"role\":\"grid_import\",\"expression\":\"a-b\"}", role);
|
||||
|
||||
Assert.Null(MeterMeta.Role(updated));
|
||||
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"/meters", "/trends", "/solar", "/consumables", "/import",
|
||||
"/admin/tariffs", "/admin/energy-types", $"/meters/{hausId}",
|
||||
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
|
||||
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
|
||||
})
|
||||
{
|
||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The HA connection tester must fail closed on missing config <em>before</em> any network call —
|
||||
/// so a misconfigured connector gives a clear message, never an exception. The stub factory throws
|
||||
/// if the tester ever tries to create an HttpClient, proving these branches never reach the network.
|
||||
/// </summary>
|
||||
public sealed class HaConnectionTesterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Missing_base_url_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "", tokenEnv: "SOME_TOKEN", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_token_env_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", tokenEnv: "", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("env-var", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_token_env_var_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(
|
||||
baseUrl: "http://ha.local:8123", tokenEnv: "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("is not set", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HaConnectionTester NewTester() =>
|
||||
new(new ThrowingHttpClientFactory(), NullLogger<HaConnectionTester>.Instance);
|
||||
|
||||
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) =>
|
||||
throw new InvalidOperationException("Network must not be touched for a config-guard failure.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user