Tau.Acuvim/portal/TESTING.md
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

130 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Tau Acuvim Portal — Testing
This file covers what's checked-in (backend unit tests) and what to do manually or add later (integration, frontend).
## Backend unit tests
xUnit project under `portal/tests/Tau.Acuvim.Portal.Tests`, matching the console's test stack (xUnit 2.9.3, Microsoft.NET.Test.Sdk 17.14.1, EF Core InMemory 10.0.8, coverlet 6.0.4).
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests
dotnet test
```
What's covered:
| Suite | What it locks down |
|---|---|
| `CostCalculatorTests` | Period selection by day-of-week + time, overlap-first-wins, default-rate fallback, VAT, fixed charges, `includeFixedMonthlyCharge=false`, timezone conversion (sample inside vs outside the local-time window), empty-samples edge case, null-tariff guard. |
| `ConnectionStringResolverTests` | All four precedence branches: explicit conn string wins; auto-provision in non-Production builds from `TimescaleDb` block; auto-provision in Production throws; no conn string + auto-provision off throws. |
| `TariffValidatorTests` | Tariff-level rules (name, effective dates, non-negative rates, VAT 0100) and per-period rules (name, days, time order, midnight-wrap rejection, negative rate, bad time format). |
| `DayOfWeekFlagTests` | Each weekday maps correctly; `Weekdays`/`Weekends`/`All` compose. |
## What's not covered by code (deliberate)
### Authorization smoke tests
A `WebApplicationFactory<Program>` test that signs in a fake user and hits an admin endpoint would catch role-attribute regressions. Building one cleanly means swapping the EF provider, neutering `MigrateAsync` / `TimescaleBootstrapper` / the NpgSql health check, and stubbing cookie sign-in. That's a fair amount of plumbing for "the policy attribute is wired."
**Add it if you start seeing role bypass regressions.** Skeleton:
```csharp
public class AuthorizationSmokeTests : IClassFixture<WebApplicationFactory<Program>> { }
```
— with `WebApplicationFactoryConfigureWebHost` swapping `AppDbContext` for InMemory, removing the hosted services that need Timescale, and configuring a test authentication scheme.
### Frontend component tests
Recommend Vitest + React Testing Library + jsdom. Not scaffolded in this repo on purpose — a half-built test runner is worse than none.
When you want them:
```powershell
cd portal/frontend
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8
```
Add to `vite.config.ts`:
```ts
test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], globals: true }
```
High-value first tests:
- `RequireAuth` — renders children when authed, redirects to `/login` when not.
- `RequireRole` — renders 403 panel when role missing, children when present.
- `BrandingProvider` — applies CSS variables and document.title after fetch resolves.
- `PeriodEditor` — toggling day-of-week buttons updates the bitmask correctly (this is the trickiest pure-logic spot in the UI).
## Manual integration scenario
End-to-end smoke test. Run after any phase change.
### 0. Reset
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal
docker compose down -v # wipe DB + Grafana volumes
docker compose up -d
```
### 1. Migrate + seed
```powershell
cd src/Tau.Acuvim.Portal
dotnet run
```
Expect in the log:
- `Database connection resolved via …`
- `Applied migration '<name>'` (one per pending)
- `TimescaleDB hypertable for monitoring.PowerMeasurements is ready (chunk interval: 7 days)`
- `Seeded default admin admin@example.com`
### 2. Frontend up
```powershell
cd portal/frontend
npm install
npm run dev
```
### 3. Walk the surface
1. Browse `http://localhost:5174/login` → branded card from template defaults.
2. Sign in as `admin@example.com` / `ChangeMe123!`.
3. **Settings → Branding** → change primary colour + footer + upload a small PNG. Save → re-themes everywhere without reload.
4. **Settings → Rates** → create municipality "Cape Town" TZ `Africa/Johannesburg` → add tariff with three periods (peak/standard/off-peak per the README example).
5. **Settings → Users** → create `user@example.com` / `Password1`, not admin. Sign out, log in as user → only **Dashboard** and **Dashboards** in nav; `/admin/sites` shows 403. Sign back in as admin.
6. **Sites** → create "Warehouse A" linked to Cape Town → add device with external ID `ABC0001-MAIN-01`.
7. Ingest via Swagger (`/swagger`) or:
```
POST /api/ingest/measurements
[
{"time":"2026-05-17T10:00:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":12.5,"energyImportedKwh":1000},
{"time":"2026-05-17T10:15:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":13.1,"energyImportedKwh":1003.3}
]
```
`{accepted:2, rejected:0}`. Re-post → same result, no duplicates (`ON CONFLICT`).
8. `GET /api/measurements?deviceId=<guid>&from=2026-05-17T00:00:00Z&to=2026-05-17T23:59:59Z&bucket=hour` → one bucket with `sampleCount: 2`.
9. **Dashboards** → "Power Overview" iframe loads; device dropdown lists the device; chart renders the two samples.
10. **Settings → App config** → confirm DB host = `timescaledb`, port `5432`, db `power_monitoring`, **no password anywhere**.
### 4. Edges to poke
| Check | Expected |
|---|---|
| Wrong password 6 times | 6th try locks the account for 15 min (Identity lockout). |
| `dotnet run` with `ASPNETCORE_ENVIRONMENT=Production` + default admin password | App refuses to start with explicit error. |
| Restart `dotnet run` against existing DB | "Seeded default admin" line absent (idempotent). Bootstrapper re-confirms Timescale hypertable without error. |
| Delete a site with devices+measurements | Cascade removes everything; UI updates. |
| Create a period with EndTime ≤ StartTime in **Settings → Rates** | Backend 400 with "no midnight wrap" message. |
| `GET /api/admin/config-overview` as non-admin | 403. |
| Upload a 3 MB JPG to branding logo | 400 "Logo must be <= 2 MB". |
## Local dev troubleshooting
- **EF migration error on startup** — usually a schema drift since the last migration. Run `dotnet ef migrations add <Name>` to capture the diff, commit, restart.
- **`TimescaleBootstrapper` complains about hypertable** — the table was already created by a previous run with different settings. Drop the table or recreate the DB volume.
- **`POST /api/ingest/measurements` returns `{accepted: 0, rejected: N}`** — the device's `ExternalId` doesn't match anything in the `monitoring.Devices` table. Create the device first under Sites.
- **Dashboards page shows iframe but no chart** — datasource UID mismatch. Confirm `grafana/provisioning/datasources/timescaledb.yml` sets `uid: timescaledb` and the dashboard JSON's panels reference the same.