From e2232787714e3e5561e25da84e6e90235a676f43 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Mon, 13 Jul 2026 12:25:03 +0200 Subject: [PATCH] =?UTF-8?q?M7:=20release=20polish=20=E2=80=94=20export/imp?= =?UTF-8?q?ort,=20docs,=20easy=20docker=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr --- .github/workflows/ci.yml | 28 +++ .github/workflows/docker-publish.yml | 50 ++++++ .github/workflows/version-tag.yml | 50 ++++++ CLAUDE.md | 37 ++-- README.md | 78 +++++++++ VERSION | 1 + deploy/build-and-push.ps1 | 32 ++++ deploy/unraid-template.xml | 26 +++ docs/wiring.md | 72 ++++++++ src/App/Api/ApiEndpoints.cs | 11 ++ src/Infrastructure/Backup/ExportDocument.cs | 25 +++ src/Infrastructure/Backup/ExportService.cs | 159 ++++++++++++++++++ src/Infrastructure/DependencyInjection.cs | 1 + .../Integration.Tests/ExportRoundTripTests.cs | 76 +++++++++ 14 files changed, 634 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/version-tag.yml create mode 100644 README.md create mode 100644 VERSION create mode 100644 deploy/build-and-push.ps1 create mode 100644 deploy/unraid-template.xml create mode 100644 docs/wiring.md create mode 100644 src/Infrastructure/Backup/ExportDocument.cs create mode 100644 src/Infrastructure/Backup/ExportService.cs create mode 100644 tests/Integration.Tests/ExportRoundTripTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3de74a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +# Build + test on push/PR. Integration tests spin a TimescaleDB via Testcontainers, which needs +# a Docker daemon — available on the ubuntu-latest runner. +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release + + - name: Test + run: dotnet test --no-build -c Release --logger "trx;LogFileName=test-results.trx" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..5f1a5c0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,50 @@ +# Builds and pushes the multi-arch MeterVault image on a vX.Y.Z tag (SDD §11). +# Default registry is GHCR; change REGISTRY/IMAGE below (one place) to target the FinalFactory +# registry (e.g. REGISTRY: git.finalfactory.de) and set the matching login secrets. +name: docker-publish + +on: + push: + tags: ["v*"] + workflow_dispatch: {} + +env: + REGISTRY: ghcr.io + IMAGE: ${{ github.repository }} # ghcr.io//metervault + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Derive version + id: ver + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to the registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.ver.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/version-tag.yml b/.github/workflows/version-tag.yml new file mode 100644 index 0000000..57bd0f7 --- /dev/null +++ b/.github/workflows/version-tag.yml @@ -0,0 +1,50 @@ +# Turns an edit to the VERSION file on main into an annotated vX.Y.Z tag (the "easy release" +# ergonomics from MQTTower). The tag then triggers docker-publish.yml. A tag pushed with +# GITHUB_TOKEN would not trigger other workflows, so this job uses a PAT if provided +# (RELEASE_PAT), otherwise it still tags and you can trigger the image build manually. +name: version-tag + +on: + push: + branches: [main] + paths: + - "VERSION" + +permissions: + contents: write + +concurrency: + group: version-tag + cancel-in-progress: true + +jobs: + tag-if-newer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create tag if VERSION is the new highest semver + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + set -euo pipefail + RAW="$(tr -d ' \r\n' < VERSION)" + VERSION="${RAW#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "VERSION '$RAW' is not X.Y.Z"; exit 1 + fi + TAG="v${VERSION}" + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists; nothing to do."; exit 0 + fi + HIGHEST="$(git tag -l 'v*' | sort -V | tail -n1 || true)" + if [ -n "$HIGHEST" ] && [ "$(printf '%s\n%s\n' "$HIGHEST" "$TAG" | sort -V | tail -n1)" != "$TAG" ]; then + echo "${TAG} is not greater than existing ${HIGHEST}; refusing."; exit 1 + fi + git config user.name "$GIT_AUTHOR_NAME" + git config user.email "$GIT_AUTHOR_EMAIL" + git tag -a "$TAG" -m "Release $TAG" + git push origin "refs/tags/${TAG}" diff --git a/CLAUDE.md b/CLAUDE.md index da14482..611b8c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**. -**Status: pre-code.** The repository currently contains only the design spec and reference data — no solution, projects, or build yet. The first coding task is milestone **M0** (scaffold) from the spec. +**Status: implemented (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits. ## Source of truth @@ -21,29 +21,42 @@ MeterVault is a self-hosted, local-first energy & utility metering platform: it .NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR. -## Intended project layout (created during M0, per SDD §11) +## Project layout ``` -/src/Core domain entities, enums, interfaces, expression evaluator (no infra deps) -/src/Infrastructure EF Core + Npgsql, Dapper repos, Timescale raw-SQL migrations, MQTT/HA clients, CSV importer -/src/App ASP.NET Core host: Blazor Server UI + REST API + hosted workers -/tests/Core.Tests unit: deltas, swaps, tariff resolution, oil rate, CSV parsing -/tests/Integration.Tests Testcontainers (Timescale): ingest→aggregate→cost e2e -/tests/fixtures the 4 reference CSVs + expected outputs -/deploy Dockerfile, docker-compose.yml (app + timescaledb), unraid-template.xml +/src/Core domain entities + enums; pure Normalization engine (mode strategies, + expression evaluator); Parsing (German dialect); Costing (TariffResolver) +/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale); + Import (CsvImporter, profiles, ImportService), Ingestion (MQTT/HA workers, + IngestionService), Normalization service, Costing/Dashboard/Backup services +/src/App ASP.NET Core host: Blazor Server UI (Components/), REST API (Api/), hosted + workers, Program.cs (Serilog, migrate+seed on startup, /healthz) +/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, tariff resolver +/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures, + import commit/revert, ingestion, cost, CAgg refresh, API, export, render +/deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml ``` -## Commands (apply once M0 has scaffolded the solution) +Central package versions live in `Directory.Packages.props`; shared build/style in +`Directory.Build.props` + `.editorconfig`. Snake_case table/column mapping via +`UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`). + +## Commands ```powershell dotnet build # build the solution dotnet test # all tests (Integration.Tests needs Docker for Testcontainers) dotnet test tests/Core.Tests # unit tests only (no Docker needed) -dotnet test --filter "FullyQualifiedName~Csv" # a single test / class by name filter -dotnet run --project src/App # run app + workers locally +dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area +dotnet ef migrations add -p src/Infrastructure -s src/App -o Persistence/Migrations +dotnet run --project src/App # run app + workers locally (needs a Timescale DB) docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together ``` +**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable/CAgg DDL lives in +raw-SQL migrations; continuous-aggregate creation + policies use `migrationBuilder.Sql(..., suppressTransaction: true)`, one statement each; CAgg policy `end_offset` must be ≥ one bucket. Tests +pause the compression job (historical fixture data would otherwise deadlock imports). + ## Core architecture (the part that spans multiple files) **Data pipeline — one direction, layered (SDD §4.2, §5, §7):** diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ceda89 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# MeterVault + +A self-hosted, local-first **energy & utility metering platform**. MeterVault pulls meter data +from Home Assistant, Tasmota and raw MQTT on a schedule, stores every reading timestamped and +immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types +(electricity, water, heating oil, gas, district heat, …) and meters are **user-defined — nothing +is hardcoded**. + +Successor to a hand-maintained *Energiebilanz* spreadsheet. See [`docs/SDD.md`](docs/SDD.md) for the +full design. + +## Features + +- **Automatic ingestion** from MQTT/Tasmota (persistent subscriptions) and Home Assistant (REST + poll or push), plus manual entry, a REST push API, and CSV import. +- **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption** + layer on top — reproducible, auditable. +- **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable, + direct delta, instant rate, virtual). Handles meter swaps, counter resets, tank dip-sticks with + calibration, and **virtual meters** defined by an expression (PV self-consumption, savings, net). +- **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type / + per meter; **cost categories** decoupled from energy types; meterless manual costs. +- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw. +- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/ + less" difference view, trends, meter list, one-click reference-data load, CSV dry-run. +- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik). +- **JSON config export/import** for portability; Docker Compose + multi-arch image. + +## Quick start (Docker) + +```bash +docker compose -f deploy/docker-compose.yml up -d +# open http://localhost:8080 → Import → "Load reference data" for a populated demo +# API docs at http://localhost:8080/swagger +``` + +Configuration is via environment variables (`Section__Key` double-underscore mapping), e.g.: + +| Variable | Purpose | +|----------|---------| +| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | +| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) | +| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header | +| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy | +| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers | + +Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name* +of an environment variable, resolved at runtime. + +## Pushing readings (Home Assistant) + +```bash +curl -X POST http://localhost:8080/api/v1/readings \ + -H "X-Api-Key: $METERVAULT_API_KEY" -H "Content-Type: application/json" \ + -d '[{"meterId": 1, "time": "2026-01-01T12:00:00Z", "value": 47200}]' +``` + +See [`docs/wiring.md`](docs/wiring.md) for wiring up Tasmota, MQTT and Home Assistant. + +## Development + +```bash +dotnet build +dotnet test # integration tests spin a TimescaleDB via Testcontainers (needs Docker) +dotnet test tests/Core.Tests # fast unit tests, no Docker +dotnet run --project src/App +``` + +Architecture, project layout and conventions live in [`CLAUDE.md`](CLAUDE.md). + +## Releasing + +Edit the [`VERSION`](VERSION) file on `main`; CI tags `vX.Y.Z` and builds/pushes a multi-arch image +(`.github/workflows/`). Locally: `pwsh deploy/build-and-push.ps1 -Push`. + +## License + +Not yet chosen (see SDD §14). Add a `LICENSE` before the first public tag. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/deploy/build-and-push.ps1 b/deploy/build-and-push.ps1 new file mode 100644 index 0000000..9e64308 --- /dev/null +++ b/deploy/build-and-push.ps1 @@ -0,0 +1,32 @@ +#!/usr/bin/env pwsh +# Local convenience: build the multi-arch MeterVault image and push it. +# Usage: ./deploy/build-and-push.ps1 -Registry ghcr.io -Image finalfactory/metervault +# Version is read from the VERSION file; images are tagged : and :latest. + +param( + [string]$Registry = "ghcr.io", + [string]$Image = "finalfactory/metervault", + [string[]]$Platforms = @("linux/amd64", "linux/arm64"), + [switch]$Push +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$version = (Get-Content (Join-Path $root "VERSION")).Trim() +$ref = "$Registry/$Image" + +Write-Host "Building $ref:$version ($($Platforms -join ', '))" -ForegroundColor Cyan + +$args = @( + "buildx", "build", + "--platform", ($Platforms -join ","), + "-f", (Join-Path $root "deploy/Dockerfile"), + "-t", "$ref`:$version", + "-t", "$ref`:latest" +) +if ($Push) { $args += "--push" } else { $args += "--load" } +$args += $root + +& docker @args +if ($LASTEXITCODE -ne 0) { throw "docker buildx build failed" } +Write-Host "Done." -ForegroundColor Green diff --git a/deploy/unraid-template.xml b/deploy/unraid-template.xml new file mode 100644 index 0000000..b355a54 --- /dev/null +++ b/deploy/unraid-template.xml @@ -0,0 +1,26 @@ + + + + MeterVault + ghcr.io/finalfactory/metervault:latest + https://ghcr.io/finalfactory/metervault + bridge + bash + false + Self-hosted energy & utility metering: ingest from Home Assistant/Tasmota/MQTT, normalize to consumption, and produce cost dashboards. Needs a TimescaleDB instance. + HomeAutomation: Tools: Productivity: + http://[IP]:[PORT:8080]/ + https://raw.githubusercontent.com/FinalFactory/MeterVault/main/docs/icon.png + + 8080 + + Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme + + Europe/Berlin + + + + false + diff --git a/docs/wiring.md b/docs/wiring.md new file mode 100644 index 0000000..04f1325 --- /dev/null +++ b/docs/wiring.md @@ -0,0 +1,72 @@ +# Wiring up sources + +MeterVault ingests from MQTT/Tasmota and Home Assistant. Sources are attached to meters; each +source's `config` JSON says where the value comes from. Secrets are referenced by environment +variable name, never stored in the database. + +## 1. Create an MQTT broker endpoint + +`ingestion_endpoint` (type `MqttBroker`) config: + +```json +{ + "host": "192.168.1.10", + "port": 1883, + "usernameEnv": "MQTT_USER", + "passwordEnv": "MQTT_PASS", + "extraTopics": ["tele/+/SENSOR"] +} +``` + +Set `MQTT_USER` / `MQTT_PASS` in the container environment. The worker connects on startup and +resubscribes automatically after outages. + +## 2. Tasmota plug → electricity meter + +Create a `Tasmota` source on the meter with: + +```json +{ "topic": "tele/plug1/SENSOR", "path": "ENERGY.Total" } +``` + +Tasmota publishes e.g. `{"Time":"2026-01-01T12:00:00","ENERGY":{"Total":1234.56,"Today":1.2,"Power":50}}`. +MeterVault reads `ENERGY.Total`, and uses the payload's `Time` field as the timestamp. Use +`ENERGY.Today` for daily-delta meters (`direct_delta` mode) or `ENERGY.Power` for `instant_rate`. +`scale`/`offset` on the source convert units (e.g. Wh → kWh with `scale: 0.001`). + +## 3. Raw MQTT sensor + +Same as Tasmota but source type `Mqtt`; point `path` at the JSON field, or omit it for a bare +numeric payload. `timePath` names a timestamp field in the payload if present. + +## 4. Home Assistant + +Two options: + +**A — HA pushes to MQTT.** Configure an HA MQTT sensor/automation to publish to a topic and treat +it as an MQTT source (above). No HA endpoint needed. + +**B — MeterVault polls HA.** Create an `ingestion_endpoint` (type `HomeAssistant`): + +```json +{ "baseUrl": "http://homeassistant.local:8123", "tokenEnv": "HA_TOKEN" } +``` + +and a `HomeAssistant` source on the meter: + +```json +{ "entityId": "sensor.house_power", "attribute": null, "pollSeconds": 60 } +``` + +Set `HA_TOKEN` (a long-lived access token) in the environment. Numeric state (or a named +`attribute`) is read every `pollSeconds`; `unavailable`/`unknown` states are skipped. + +**C — HA pushes to the REST API.** POST to `/api/v1/readings` with an `X-Api-Key` header (see the +README). Good when HA should drive the cadence. + +## Notes + +- Cumulative registers reject spurious **decreases** unless a `counter_reset`/`meter_swap` event + explains them — record swaps via `POST /api/v1/events`. +- High-frequency sources: MeterVault stores raw readings idempotently on `(meter, time)`; use + Tasmota's `TelePeriod` and per-source sampling to bound volume. diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs index f26706b..137b2fa 100644 --- a/src/App/Api/ApiEndpoints.cs +++ b/src/App/Api/ApiEndpoints.cs @@ -112,6 +112,17 @@ public static class ApiEndpoints .Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus }) .ToListAsync(ct))); + api.MapGet("/export", async (MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) => + Results.Text(await export.ExportJsonAsync(ct), "application/json")) + .WithSummary("Export configuration + hand-entered data as portable JSON (SDD §10)."); + + api.MapPost("/import", async (HttpRequest request, MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) => + { + using var reader = new StreamReader(request.Body); + await export.ImportJsonAsync(await reader.ReadToEndAsync(ct), ct); + return Results.Ok(); + }).WithSummary("Restore a JSON export into an empty instance (remaps ids)."); + return app; } } diff --git a/src/Infrastructure/Backup/ExportDocument.cs b/src/Infrastructure/Backup/ExportDocument.cs new file mode 100644 index 0000000..a90cf55 --- /dev/null +++ b/src/Infrastructure/Backup/ExportDocument.cs @@ -0,0 +1,25 @@ +using MeterVault.Core.Domain; + +namespace MeterVault.Infrastructure.Backup; + +/// +/// A portable JSON snapshot of MeterVault's configuration and hand-entered data (SDD §10). Covers +/// everything except the bulk time-series (raw readings / consumption), which is restored from the +/// live sources or a database dump — this keeps the export small and human-readable. +/// +public sealed class ExportDocument +{ + public int SchemaVersion { get; set; } = 1; + + public List EnergyTypes { get; set; } = []; + public List CostCategories { get; set; } = []; + public List Meters { get; set; } = []; + public List IngestionEndpoints { get; set; } = []; + public List MeterSources { get; set; } = []; + public List Tanks { get; set; } = []; + public List Tariffs { get; set; } = []; + public List CostCategoryMembers { get; set; } = []; + public List ManualCosts { get; set; } = []; + public List MeterEvents { get; set; } = []; + public List AppSettings { get; set; } = []; +} diff --git a/src/Infrastructure/Backup/ExportService.cs b/src/Infrastructure/Backup/ExportService.cs new file mode 100644 index 0000000..0c89655 --- /dev/null +++ b/src/Infrastructure/Backup/ExportService.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Backup; + +/// +/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance, +/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids. +/// +public sealed class ExportService(MeterVaultDbContext db) +{ + private static readonly JsonSerializerOptions Json = new() + { + WriteIndented = true, + ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles, + }; + + private readonly MeterVaultDbContext _db = db; + + public async Task ExportJsonAsync(CancellationToken cancellationToken = default) + { + // Load without navigation includes so serialization is a clean, cycle-free tree. + var document = new ExportDocument + { + EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + IngestionEndpoints = await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false), + }; + + return JsonSerializer.Serialize(document, Json); + } + + public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default) + { + var doc = JsonSerializer.Deserialize(json, Json) + ?? throw new InvalidOperationException("Empty or invalid export document."); + + await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + var typeMap = new Dictionary(); + foreach (var type in doc.EnergyTypes) + { + var old = type.Id; + type.Id = 0; + _db.EnergyTypes.Add(type); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + typeMap[old] = type.Id; + } + + var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false); + + var meterMap = new Dictionary(); + foreach (var meter in doc.Meters) + { + var old = meter.Id; + meter.Id = 0; + meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId); + meter.EnergyType = null; + _db.Meters.Add(meter); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + meterMap[old] = meter.Id; + } + + var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false); + + foreach (var source in doc.MeterSources) + { + source.Id = 0; + source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId); + source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null; + source.Meter = null; + source.Endpoint = null; + _db.MeterSources.Add(source); + } + + foreach (var tank in doc.Tanks) + { + tank.Id = 0; + tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId); + tank.Meter = null; + _db.Tanks.Add(tank); + } + + foreach (var tariff in doc.Tariffs) + { + tariff.Id = 0; + tariff.ScopeId = tariff.ScopeType switch + { + TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id), + TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id), + _ => tariff.ScopeId, + }; + _db.Tariffs.Add(tariff); + } + + foreach (var member in doc.CostCategoryMembers) + { + member.Id = 0; + member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId); + member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null; + member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null; + member.Category = null; + _db.CostCategoryMembers.Add(member); + } + + foreach (var cost in doc.ManualCosts) + { + cost.Id = 0; + cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null; + cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null; + _db.ManualCosts.Add(cost); + } + + foreach (var meterEvent in doc.MeterEvents) + { + meterEvent.Id = 0; + meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId); + _db.MeterEvents.Add(meterEvent); + } + + foreach (var setting in doc.AppSettings) + { + if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false)) + { + _db.AppSettings.Add(setting); + } + } + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await tx.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task> InsertMappedAsync( + List items, Func getId, Action clearId, CancellationToken cancellationToken) + where T : class + { + var map = new Dictionary(); + foreach (var item in items) + { + var old = getId(item); + clearId(item, 0); + _db.Add(item); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + map[old] = getId(item); + } + + return map; + } +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index b1a8205..76363cb 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -30,6 +30,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/tests/Integration.Tests/ExportRoundTripTests.cs b/tests/Integration.Tests/ExportRoundTripTests.cs new file mode 100644 index 0000000..16df476 --- /dev/null +++ b/tests/Integration.Tests/ExportRoundTripTests.cs @@ -0,0 +1,76 @@ +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Backup; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Integration.Tests; + +/// +/// JSON config export/import (SDD §10): a round-trip through an emptied database preserves the +/// relationships (meter → energy type, meter-scoped tariff, category membership) after id remapping. +/// +[Collection("Timescale")] +public sealed class ExportRoundTripTests(TimescaleFixture fx) +{ + [Fact] + public async Task Export_then_import_preserves_relationships() + { + string json; + + await using (var db = fx.CreateContext()) + { + await WipeAllAsync(db); + await DatabaseSeeder.SeedAsync(db); + + var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); + var strom = await db.CostCategories.FirstAsync(c => c.Name == "Strom"); + var meter = new Meter { Name = "Export Meter", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; + db.Meters.Add(meter); + await db.SaveChangesAsync(); + + db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice, Value = 0.30, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) }); + db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = strom.Id, MeterId = meter.Id }); + await db.SaveChangesAsync(); + + json = await new ExportService(db).ExportJsonAsync(); + } + + await using (var db = fx.CreateContext()) + { + await WipeAllAsync(db); + await new ExportService(db).ImportJsonAsync(json); + } + + await using (var db = fx.CreateContext()) + { + var meter = await db.Meters.SingleAsync(m => m.Name == "Export Meter"); + Assert.True(await db.EnergyTypes.AnyAsync(t => t.Id == meter.EnergyTypeId)); + + var tariff = await db.Tariffs.SingleAsync(t => t.ScopeType == TariffScope.Meter); + Assert.Equal(meter.Id, tariff.ScopeId); + + var member = await db.CostCategoryMembers.SingleAsync(); + Assert.Equal(meter.Id, member.MeterId); + Assert.True(await db.CostCategories.AnyAsync(c => c.Id == member.CategoryId)); + + await WipeAllAsync(db); + } + } + + private static async Task WipeAllAsync(MeterVaultDbContext db) + { + await db.Consumption.ExecuteDeleteAsync(); + await db.Readings.ExecuteDeleteAsync(); + await db.MeterEvents.ExecuteDeleteAsync(); + await db.ManualCosts.ExecuteDeleteAsync(); + await db.CostCategoryMembers.ExecuteDeleteAsync(); + await db.Tariffs.ExecuteDeleteAsync(); + await db.Tanks.ExecuteDeleteAsync(); + await db.MeterSources.ExecuteDeleteAsync(); + await db.Meters.ExecuteDeleteAsync(); + await db.IngestionEndpoints.ExecuteDeleteAsync(); + await db.CostCategories.ExecuteDeleteAsync(); + await db.EnergyTypes.ExecuteDeleteAsync(); + await db.AppSettings.ExecuteDeleteAsync(); + } +}