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>
130 lines
6.6 KiB
Markdown
130 lines
6.6 KiB
Markdown
# 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 0–100) 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.
|