New top-level portal/ project, peer to console/ and firmware/. Delivers a .NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no changes to existing console or firmware. Backend (src/Tau.Acuvim.Portal/): - .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout). - Single AppDbContext with identity / app / monitoring schemas. - MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation) + IdentityBootstrapper (seeded admin + branding) on startup. - Pure CostCalculator + DB-backed RateService for tariffs (effective-dated, TOU periods, VAT, fixed charges, per-municipality timezone). - BrandingService with logo upload to mounted volume. - Time-series ingest + bucketed query services (time_bucket aggregates, ON CONFLICT for idempotent re-delivery). - ConfigOverviewService with redaction-by-construction (passwords never in payload). - DataProtection keys persisted to /data/keys volume for cookie survival across container restarts. Frontend (frontend/): - React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query. - BrandingProvider + ThemedRoot for live re-themed white-labelling. - RequireAuth / RequireRole guards. - Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin), Settings tabs (Branding / Rates / Users / Grafana / App config). Infra: - Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose files. Three services per customer; Traefik subdomain + same-origin /grafana path-prefix routing wired with labels. - Grafana 11 with provisioned timescaledb datasource (uid pinned) and starter power-overview.json dashboard with device template variable. - Compose project name documented as lowercase (Compose v2 requirement). Tests (tests/Tau.Acuvim.Portal.Tests/): - xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap, VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl. Production refusal), TariffValidator, DayOfWeekFlag. - All passing locally against .NET 10. Docs: - README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer provisioning, secret rotation, backup, troubleshooting), TESTING.md (manual integration scenarios, frontend test scaffolding recipe). Production safety guards: - Refuses to start if Authentication:DefaultAdminPassword is unchanged default in Production. - Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in Production. - Prod Grafana ships with anonymous off and auth mode unset (three options documented in README Security) so iframe refuses to load until a deliberate prod auth choice is made. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.5 KiB
C#
181 lines
6.5 KiB
C#
using Tau.Acuvim.Portal.Domain.Rates;
|
|
using Tau.Acuvim.Portal.Services;
|
|
|
|
namespace Tau.Acuvim.Portal.Tests;
|
|
|
|
public class CostCalculatorTests
|
|
{
|
|
private static Tariff BuildTariff(
|
|
decimal defaultRate = 2.50m,
|
|
decimal fixedCharge = 100m,
|
|
decimal vatPct = 15m,
|
|
params TariffPeriod[] periods)
|
|
{
|
|
var t = new Tariff
|
|
{
|
|
Id = 1,
|
|
Name = "Test",
|
|
EffectiveFrom = new DateOnly(2026, 1, 1),
|
|
DefaultRatePerKwh = defaultRate,
|
|
FixedMonthlyCharge = fixedCharge,
|
|
VatPercentage = vatPct,
|
|
IsActive = true,
|
|
Periods = periods.ToList()
|
|
};
|
|
return t;
|
|
}
|
|
|
|
private static TariffPeriod Period(string name, DayOfWeekFlag days, string start, string end, decimal rate) => new()
|
|
{
|
|
Name = name,
|
|
DaysOfWeek = days,
|
|
StartTime = TimeOnly.Parse(start),
|
|
EndTime = TimeOnly.Parse(end),
|
|
RatePerKwh = rate
|
|
};
|
|
|
|
[Fact]
|
|
public void EmptySamples_ReturnsZeroBase_StillIncludesFixedAndVat()
|
|
{
|
|
var t = BuildTariff();
|
|
var calc = new CostCalculator();
|
|
|
|
var r = calc.Calculate(t, Array.Empty<ConsumptionSample>());
|
|
|
|
Assert.Equal(0m, r.BaseCost);
|
|
Assert.Equal(100m, r.FixedCharges);
|
|
Assert.Equal(100m, r.Subtotal);
|
|
Assert.Equal(15m, r.Vat);
|
|
Assert.Equal(115m, r.TotalCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoPeriods_UsesDefaultRate()
|
|
{
|
|
var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0);
|
|
var calc = new CostCalculator();
|
|
var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 17, 10, 0, 0, DateTimeKind.Utc), 5m) };
|
|
|
|
var r = calc.Calculate(t, samples);
|
|
|
|
Assert.Equal(10m, r.BaseCost);
|
|
Assert.Equal(10m, r.TotalCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void MatchingPeriod_UsesPeriodRate()
|
|
{
|
|
var peak = Period("Peak", DayOfWeekFlag.Weekdays, "17:00", "20:00", 5m);
|
|
var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0, periods: peak);
|
|
var calc = new CostCalculator();
|
|
// Sunday 17:30 UTC — Sunday is NOT in Weekdays, expect default
|
|
var sundaySample = new ConsumptionSample(new DateTime(2026, 5, 17, 17, 30, 0, DateTimeKind.Utc), 1m);
|
|
// Monday 18:00 UTC — should hit peak
|
|
var mondaySample = new ConsumptionSample(new DateTime(2026, 5, 18, 18, 0, 0, DateTimeKind.Utc), 1m);
|
|
|
|
var r = calc.Calculate(t, new[] { sundaySample, mondaySample });
|
|
|
|
Assert.Equal(2m + 5m, r.BaseCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleOutsideAllPeriods_FallsBackToDefault()
|
|
{
|
|
var morning = Period("Morning", DayOfWeekFlag.All, "06:00", "09:00", 4m);
|
|
var t = BuildTariff(defaultRate: 1m, fixedCharge: 0, vatPct: 0, periods: morning);
|
|
var calc = new CostCalculator();
|
|
var sample = new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 10m);
|
|
|
|
var r = calc.Calculate(t, new[] { sample });
|
|
|
|
Assert.Equal(10m, r.BaseCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void OverlappingPeriods_FirstByStartTimeWins()
|
|
{
|
|
var early = Period("Early", DayOfWeekFlag.All, "08:00", "18:00", 5m);
|
|
var late = Period("Late", DayOfWeekFlag.All, "10:00", "14:00", 99m);
|
|
var t = BuildTariff(0m, 0m, 0m, late, early);
|
|
var calc = new CostCalculator();
|
|
var noonSample = new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 1m);
|
|
|
|
var r = calc.Calculate(t, new[] { noonSample });
|
|
|
|
Assert.Equal(5m, r.BaseCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void TimezoneConversion_ShiftsSampleIntoLocalPeriod()
|
|
{
|
|
var sastEvening = Period("SAST evening", DayOfWeekFlag.All, "17:00", "20:00", 10m);
|
|
var t = BuildTariff(defaultRate: 1m, fixedCharge: 0, vatPct: 0, periods: sastEvening);
|
|
var calc = new CostCalculator();
|
|
var jhb = TimeZoneInfo.CreateCustomTimeZone("SAST", TimeSpan.FromHours(2), "SAST", "SAST");
|
|
|
|
// 16:00 UTC == 18:00 SAST → inside the period
|
|
var inWindow = new ConsumptionSample(new DateTime(2026, 5, 18, 16, 0, 0, DateTimeKind.Utc), 1m);
|
|
// 16:00 UTC default behaviour (UTC tz) is outside the period → default rate
|
|
var defaultR = calc.Calculate(t, new[] { inWindow });
|
|
Assert.Equal(1m, defaultR.BaseCost);
|
|
|
|
// Now with SAST → in window
|
|
var sastR = calc.Calculate(t, new[] { inWindow }, timeZone: jhb);
|
|
Assert.Equal(10m, sastR.BaseCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void VatAndFixedCharge_AppliedToBase()
|
|
{
|
|
var t = BuildTariff(defaultRate: 2m, fixedCharge: 100m, vatPct: 15m);
|
|
var calc = new CostCalculator();
|
|
var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 100m) };
|
|
|
|
var r = calc.Calculate(t, samples);
|
|
|
|
Assert.Equal(200m, r.BaseCost); // 100 kWh * 2.00
|
|
Assert.Equal(100m, r.FixedCharges);
|
|
Assert.Equal(300m, r.Subtotal);
|
|
Assert.Equal(45m, r.Vat); // 15% of 300
|
|
Assert.Equal(345m, r.TotalCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void IncludeFixedMonthlyCharge_False_ExcludesFixed()
|
|
{
|
|
var t = BuildTariff(defaultRate: 2m, fixedCharge: 100m, vatPct: 10m);
|
|
var calc = new CostCalculator();
|
|
var samples = new[] { new ConsumptionSample(new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc), 10m) };
|
|
|
|
var r = calc.Calculate(t, samples, includeFixedMonthlyCharge: false);
|
|
|
|
Assert.Equal(20m, r.BaseCost);
|
|
Assert.Equal(0m, r.FixedCharges);
|
|
Assert.Equal(20m, r.Subtotal);
|
|
Assert.Equal(2m, r.Vat);
|
|
Assert.Equal(22m, r.TotalCost);
|
|
}
|
|
|
|
[Fact]
|
|
public void DayOfWeekFiltering_HonoursWeekendOnlyPeriod()
|
|
{
|
|
var weekend = Period("Weekend special", DayOfWeekFlag.Weekends, "00:00", "23:59", 0.5m);
|
|
var t = BuildTariff(defaultRate: 2m, fixedCharge: 0, vatPct: 0, periods: weekend);
|
|
var calc = new CostCalculator();
|
|
var monday = new ConsumptionSample(new DateTime(2026, 5, 18, 10, 0, 0, DateTimeKind.Utc), 10m); // Monday
|
|
var saturday = new ConsumptionSample(new DateTime(2026, 5, 16, 10, 0, 0, DateTimeKind.Utc), 10m); // Saturday
|
|
|
|
var r = calc.Calculate(t, new[] { monday, saturday });
|
|
|
|
Assert.Equal(20m + 5m, r.BaseCost); // Mon at default 2.00 + Sat at 0.50
|
|
}
|
|
|
|
[Fact]
|
|
public void NullTariff_Throws()
|
|
{
|
|
var calc = new CostCalculator();
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
calc.Calculate(null!, Array.Empty<ConsumptionSample>()));
|
|
}
|
|
}
|