using System.Text.Json; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; 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, meter topology links) /// after id remapping — and the meter ids inside virtual-meter definitions follow their meters (D-32). /// [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); } } [Fact] public async Task Links_and_virtual_definitions_follow_their_meters_to_new_ids() { string json; (int Meter, int Solar, int Sum, int Broken) old; await using (var db = fx.CreateContext()) { await WipeAllAsync(db); await DatabaseSeeder.SeedAsync(db); var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); var ghost = new Meter { Name = "Deleted before the export", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; var meter = new Meter { Name = "Export Haus", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; var solar = new Meter { Name = "Export Solar", EnergyTypeId = elec.Id, Mode = MeterMode.GenerationCounter, Unit = "kWh" }; var sum = new Meter { Name = "Export Net", EnergyTypeId = elec.Id, Mode = MeterMode.Virtual, Unit = "kWh" }; var broken = new Meter { Name = "Export Broken", EnergyTypeId = elec.Id, Mode = MeterMode.Virtual, Unit = "kWh" }; db.Meters.AddRange(ghost, meter, solar, sum, broken); await db.SaveChangesAsync(); // A definition with a key of its own next to it (the role, a totals override, …), and one naming a meter the // export will not contain. sum.Meta = VirtualDefinitionJson.Write( """{"totals":"never"}""", new VirtualDefinition($"(m{solar.Id} + m{meter.Id})", QuantityKind.Net, "kWh", VirtualCostRule.None)); broken.Meta = VirtualDefinitionJson.Write( "{}", new VirtualDefinition($"m{solar.Id} + m{ghost.Id}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts)); db.MeterLinks.AddRange( new MeterLink { FromMeterId = solar.Id, ToMeterId = sum.Id }, new MeterLink { FromMeterId = meter.Id, ToMeterId = sum.Id }, new MeterLink { FromMeterId = meter.Id, ToMeterId = solar.Id }); await db.SaveChangesAsync(); await db.Meters.Where(m => m.Id == ghost.Id).ExecuteDeleteAsync(); old = (meter.Id, solar.Id, sum.Id, broken.Id); 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 ids = await db.Meters.ToDictionaryAsync(m => m.Name, m => m.Id); var (meter, solar, sum, broken) = (ids["Export Haus"], ids["Export Solar"], ids["Export Net"], ids["Export Broken"]); // The restore numbered the meters anew, so a formula still naming the old ids would read other meters. Assert.NotEqual(old.Solar, solar); Assert.NotEqual(old.Meter, meter); var links = await db.MeterLinks.Select(l => new { l.FromMeterId, l.ToMeterId }).ToListAsync(); Assert.Equal( new[] { (meter, solar), (meter, sum), (solar, sum) }.Order(), links.Select(l => (l.FromMeterId, l.ToMeterId)).Order()); var metas = await db.Meters.ToDictionaryAsync(m => m.Id, m => m.Meta); var read = VirtualDefinitionJson.Read(metas[sum]); Assert.Equal(VirtualDefinitionReadStatus.Present, read.Status); Assert.Equal($"(m{solar} + m{meter})", read.Definition!.Expression); Assert.Equal(new[] { meter, solar }.Order(), read.Definition.ReferencedMeterIds); Assert.False(read.ReferencedIdsStale); Assert.Equal(QuantityKind.Net, read.Definition.ResultKind); Assert.Equal(VirtualCostRule.None, read.Definition.CostRule); using (var doc = JsonDocument.Parse(metas[sum])) { Assert.Equal("never", doc.RootElement.GetProperty("totals").GetString()); } // A meter the document has no row for becomes m0, which no meter has: an unknown reference, never another meter. var brokenRead = VirtualDefinitionJson.Read(metas[broken]); Assert.Equal($"m{solar} + m0", brokenRead.Definition!.Expression); Assert.Equal([0, solar], brokenRead.Definition.ReferencedMeterIds); Assert.False(await db.Meters.AnyAsync(m => m.Id == 0)); await WipeAllAsync(db); } } [Fact] public async Task A_tariff_of_a_deleted_meter_is_not_restored_onto_another_meter() { // Review virtual F2: tariff.scope_id has no foreign key, so deleting a meter the old way left its meter-scoped // price behind. A fresh instance numbers its meters from where the original did, so the dead id is given to // another restored meter, which the orphaned 0.99 EUR/kWh would then bill. string json; int firstId; await using (var db = fx.CreateContext()) { await WipeAllAsync(db); await DatabaseSeeder.SeedAsync(db); var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); var a = new Meter { Name = "Meter A", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; var b = new Meter { Name = "Old wallbox", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; var c = new Meter { Name = "Netz", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh", Meta = """{"role":"grid_import"}""" }; db.Meters.Add(a); await db.SaveChangesAsync(); db.Meters.Add(b); await db.SaveChangesAsync(); db.Meters.Add(c); await db.SaveChangesAsync(); firstId = a.Id; db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = b.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) }); await db.SaveChangesAsync(); // An orphan an older delete left behind: the meter goes, its price stays. await db.Meters.Where(m => m.Id == b.Id).ExecuteDeleteAsync(); json = await new ExportService(db).ExportJsonAsync(); } await using (var db = fx.CreateContext()) { await WipeAllAsync(db); await db.Database.ExecuteSqlRawAsync($"ALTER TABLE meter ALTER COLUMN id RESTART WITH {firstId}"); await new ExportService(db).ImportJsonAsync(json); } await using (var db = fx.CreateContext()) { var restored = await db.Meters.Select(m => m.Id).ToListAsync(); var meterTariffs = await db.Tariffs.Where(t => t.ScopeType == TariffScope.Meter).ToListAsync(); Assert.DoesNotContain(meterTariffs, t => restored.Contains(t.ScopeId!.Value)); Assert.Empty(meterTariffs); await WipeAllAsync(db); } } [Fact] public async Task A_tariff_of_a_deleted_energy_type_is_not_restored_onto_another_type() { string json; short firstId; await using (var db = fx.CreateContext()) { await WipeAllAsync(db); var kept = new EnergyType { Key = "kept", DisplayName = "Kept", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; var gone = new EnergyType { Key = "gone", DisplayName = "Gone", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; var other = new EnergyType { Key = "other", DisplayName = "Other", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; db.EnergyTypes.Add(kept); await db.SaveChangesAsync(); db.EnergyTypes.Add(gone); await db.SaveChangesAsync(); db.EnergyTypes.Add(other); await db.SaveChangesAsync(); firstId = kept.Id; db.Tariffs.Add(new Tariff { ScopeType = TariffScope.EnergyType, ScopeId = gone.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) }); await db.SaveChangesAsync(); await db.EnergyTypes.Where(t => t.Id == gone.Id).ExecuteDeleteAsync(); json = await new ExportService(db).ExportJsonAsync(); } await using (var db = fx.CreateContext()) { await WipeAllAsync(db); await db.Database.ExecuteSqlRawAsync($"ALTER TABLE energy_type ALTER COLUMN id RESTART WITH {firstId}"); await new ExportService(db).ImportJsonAsync(json); } await using (var db = fx.CreateContext()) { Assert.Empty(await db.Tariffs.Where(t => t.ScopeType == TariffScope.EnergyType).ToListAsync()); await WipeAllAsync(db); } } [Fact] public async Task Deleting_a_meter_or_an_energy_type_takes_its_own_prices_with_it() { await using var db = fx.CreateContext(); await WipeAllAsync(db); var type = new EnergyType { Key = "deleting", DisplayName = "Deleting", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); var meter = new Meter { Name = "Wallbox", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; db.Meters.Add(meter); await db.SaveChangesAsync(); db.Readings.Add(new Reading { MeterId = meter.Id, Time = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), Value = 1 }); db.Tariffs.AddRange( new Tariff { ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice, Value = 0.99, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) }, new Tariff { ScopeType = TariffScope.EnergyType, ScopeId = type.Id, Component = TariffComponent.BasePrice, Value = 9, Unit = "EUR/month", ValidFrom = new DateOnly(2024, 1, 1) }, new Tariff { ScopeType = TariffScope.Global, Component = TariffComponent.BasePrice, Value = 1, Unit = "EUR/month", ValidFrom = new DateOnly(2024, 1, 1) }); await db.SaveChangesAsync(); await EntityDeletion.DeleteMeterAsync(db, meter.Id); Assert.False(await db.Meters.AnyAsync(m => m.Id == meter.Id)); Assert.False(await db.Tariffs.AnyAsync(t => t.ScopeType == TariffScope.Meter)); await EntityDeletion.DeleteEnergyTypeAsync(db, type.Id); Assert.False(await db.EnergyTypes.AnyAsync(t => t.Id == type.Id)); Assert.Equal(TariffScope.Global, (await db.Tariffs.SingleAsync()).ScopeType); 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(); } }