Tau.Acuvim/portal/tests/Tau.Acuvim.Portal.Tests/CostCalculatorTests.cs
Diseri Pearson e17921a122 Add portal: customer-facing white-labeled monitoring stack
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>
2026-05-18 09:30:30 +02:00

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>()));
}
}