# CannaManage — Sprint 5 Test Plan

**Date:** 2026-06-12
**Author:** Patrick Plate / Lumen (Planner)
**Status:** Draft v1
**Basis:** cannamanage-sprint5-plan.md v1

---

## Test Overview

| ID | Description | Type | Target | Status |
|----|-------------|------|--------|--------|
| T-01 | Docker Compose full stack starts healthy | System | docker-compose.yml | ⬜ |
| T-02 | CORS preflight allows localhost:3000 | Integration | SecurityConfig | ⬜ |
| T-03 | Backend health endpoint responds | Integration | /actuator/health | ⬜ |
| T-04 | React Query provider mounts without error | Unit | providers.tsx | ⬜ |
| T-05 | apiFetch uses rewrite proxy correctly | Unit | api-client-browser.ts | ⬜ |
| T-06 | ApiError formats toast messages | Unit | use-api-error.ts | ⬜ |
| T-07 | Dashboard loads real club stats | Integration | dashboard/page.tsx | ⬜ |
| T-08 | Member list renders from API | Integration | members/page.tsx | ⬜ |
| T-09 | Create member persists to database | Integration | POST /members | ⬜ |
| T-10 | Edit member updates in database | Integration | PUT /members/{id} | ⬜ |
| T-11 | Distribution form records to backend | Integration | POST /distributions | ⬜ |
| T-12 | Quota exceeded returns 409 with details | Integration | ComplianceService | ⬜ |
| T-13 | Under-21 member enforces 30g limit | Integration | ComplianceService | ⬜ |
| T-14 | Batch creation adds stock | Integration | POST /stock/batches | ⬜ |
| T-15 | Batch recall changes status | Integration | PUT /stock/batches/{id}/recall | ⬜ |
| T-16 | PDF report downloads as binary | Integration | GET /reports/monthly?format=pdf | ⬜ |
| T-17 | CSV report downloads correctly | Integration | GET /reports/member-list?format=csv | ⬜ |
| T-18 | Portal login with session auth | Integration | POST /portal/login | ⬜ |
| T-19 | Portal dashboard shows real quota | Integration | GET /portal/dashboard | ⬜ |
| T-20 | Staff list loads for ADMIN | Integration | GET /staff | ⬜ |
| T-21 | Staff invite sends email | Integration | POST /staff/invite | ⬜ |
| T-22 | Permission update persists | Integration | PUT /staff/{id}/permissions | ⬜ |
| T-23 | Staff revoke removes access | Integration | DELETE /staff/{id} | ⬜ |
| T-24 | Non-ADMIN gets 403 on staff endpoints | Security | SecurityConfig | ⬜ |
| T-25 | Seed data creates deterministic test state | System | R__test_seed.sql | ⬜ |
| T-26 | Full E2E: login → distribute → verify quota | System | system-test.spec.ts | ⬜ |
| T-27 | System test exit code 0 on success | System | docker-compose.test.yml | ⬜ |
| T-28 | Error state renders when backend down | Unit | error-state.tsx | ⬜ |
| T-29 | Loading skeleton appears during fetch | Unit | skeleton-card.tsx | ⬜ |
| T-30 | Empty state shown when no data | Unit | dashboard/members pages | ⬜ |

Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped

---

## Test Cases (Detail)

### T-01: Docker Compose full stack starts healthy

**Type:** System
**Target:** `docker-compose.yml`

**Preconditions:**
- Docker Desktop running
- No port conflicts (5432, 8080, 3000)
- Images buildable (Maven + Node available)

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | `docker compose up -d` | All 3 containers reach "healthy" within 60s |
| b | `docker compose ps` | db=healthy, backend=healthy, frontend=running |
| c | `curl http://localhost:3000` | Returns HTML (login page) |
| d | `curl http://localhost:8080/actuator/health` | Returns `{"status":"UP"}` |

**Post-conditions:**
- All containers stable for 30s without restart loops

---

### T-02: CORS preflight allows localhost:3000

**Type:** Integration
**Target:** `SecurityConfig.java` CORS bean

**Preconditions:**
- Backend running on port 8080

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | OPTIONS /api/v1/members with Origin: http://localhost:3000 | 200 + Access-Control-Allow-Origin: http://localhost:3000 |
| b | OPTIONS /api/v1/members with Origin: http://evil.com | No Access-Control-Allow-Origin header (or 403) |
| c | GET /api/v1/members with Origin: http://localhost:3000 + valid JWT | 200 + CORS headers present |

---

### T-03: Backend health endpoint responds

**Type:** Integration
**Target:** Spring Boot Actuator

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | GET /actuator/health (no auth) | 200 `{"status":"UP"}` |
| b | Backend with DB down | 503 `{"status":"DOWN"}` |

---

### T-04: React Query provider mounts without error

**Type:** Unit
**Class:** `providers.tsx`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Render providers with children | No console errors, children visible |
| b | Check React Query DevTools in dev mode | DevTools panel accessible (floating button) |

---

### T-05: apiFetch uses rewrite proxy correctly

**Type:** Unit
**Class:** `api-client-browser.ts`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | `apiFetch("/members")` | Fetches `/api/backend/members` |
| b | Backend returns 401 | Throws `ApiError` with status 401 |
| c | Backend returns 500 | Throws `ApiError` with status 500, message from body |
| d | Backend returns non-JSON error | Throws `ApiError` with statusText as message |
| e | Network error (backend down) | Throws TypeError (fetch failure) |

---

### T-06: ApiError formats toast messages

**Type:** Unit
**Class:** `use-api-error.ts`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | ApiError(401, "Unauthorized") | Toast: "Sitzung abgelaufen" (destructive) |
| b | ApiError(403, "Forbidden") | Toast: "Zugriff verweigert" (destructive) |
| c | ApiError(409, "Quota exceeded: 5g remaining") | Toast: "Kontingent überschritten" with message |
| d | TypeError (network) | Toast: "Verbindungsfehler — Backend nicht erreichbar" |
| e | ApiError(500, "Internal error") | Toast: "Fehler" with generic message |

---

### T-07: Dashboard loads real club stats

**Type:** Integration
**Target:** Dashboard page + `GET /clubs/stats` (or equivalent)

**Preconditions:**
- Backend running with seed data
- User logged in as ADMIN

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Navigate to dashboard | KPI cards show numbers matching DB (5 members from seed) |
| b | Refresh page | Data reloads (React Query refetch) |
| c | Backend returns error | Error state shown with retry button |
| d | Slow response (>2s) | Skeleton cards visible during loading |

---

### T-08: Member list renders from API

**Type:** Integration
**Target:** Members page + `GET /members`

**Preconditions:**
- Seed data loaded (5 members)

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Navigate to /members | Table shows 5 seed members |
| b | Search "Müller" | Filtered to Max Müller only |
| c | Empty DB (no members) | Empty state: "Keine Mitglieder vorhanden" |
| d | Loading state | Skeleton table rows visible |

---

### T-09: Create member persists to database

**Type:** Integration
**Target:** `POST /api/v1/members`

**Preconditions:**
- Logged in as ADMIN or STAFF with MANAGE_MEMBERS permission

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid member data (name, email, DOB, phone) | 201 Created, member appears in list |
| b | Duplicate email | 409 Conflict with error message |
| c | Missing required field (firstName blank) | 400 Bad Request with validation errors |
| d | Future date of birth | 400 Bad Request (validation) |
| e | Under-18 date of birth | 400 Bad Request (CanKG minimum age) |

---

### T-10: Edit member updates in database

**Type:** Integration
**Target:** `PUT /api/v1/members/{id}`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Change phone number | 200 OK, phone updated in DB |
| b | Change status to SUSPENDED | 200 OK, member shows suspended badge |
| c | Invalid member ID | 404 Not Found |
| d | Unauthorized role | 403 Forbidden |

---

### T-11: Distribution form records to backend

**Type:** Integration
**Target:** `POST /api/v1/distributions`

**Preconditions:**
- Available batch exists (≥ requested grams)
- Member is ACTIVE
- Quota not exceeded

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | 10g Amnesia Haze to Max Müller | 201 Created, distribution ID returned |
| b | Batch available_grams decreases by 10g | GET /stock/batches/{id} shows -10g |
| c | Member quota updates | Compliance endpoint shows 10g used today |
| d | Stock chart updates after mutation | React Query invalidates batch cache |

---

### T-12: Quota exceeded returns 409 with details

**Type:** Integration
**Target:** `ComplianceService` quota enforcement

**Preconditions:**
- Member already at 24g today (daily limit = 25g)

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Request 5g distribution | 409: "Daily quota exceeded. Remaining: 1g" |
| b | Request 1g distribution | 201 Created (exactly at limit) |
| c | Request 26g in single distribution | 409: "Daily limit is 25g" |

---

### T-13: Under-21 member enforces 30g monthly limit

**Type:** Integration
**Target:** `ComplianceService` age-based quota

**Preconditions:**
- Member Jonas Fischer (DOB: 2005-11-01, age 20 in 2026) in seed data

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Check quota for Jonas | `monthlyLimitGrams: 30`, `isUnder21: true` |
| b | Distribute 29g across month | Success |
| c | Distribute 1g more (total 30g) | Success (at limit) |
| d | Distribute 1g more (total 31g) | 409: "Monthly quota exceeded for under-21 member" |

---

### T-14: Batch creation adds stock

**Type:** Integration
**Target:** `POST /api/v1/stock/batches`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | New batch: 500g Blue Dream, THC 19%, supplier "BioHemp" | 201 Created |
| b | Stock total increases by 500g | Dashboard total_stock reflects increase |
| c | Missing strain name | 400 Bad Request |
| d | Negative grams | 400 Bad Request |

---

### T-15: Batch recall changes status

**Type:** Integration
**Target:** `PUT /api/v1/stock/batches/{id}/recall`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Recall batch b-001 | Status changes to RECALLED |
| b | Recalled batch not available for distribution | Distribution form excludes recalled batches |
| c | Recall already-recalled batch | 400 Bad Request (or idempotent 200) |
| d | Invalid batch ID | 404 Not Found |

---

### T-16: PDF report downloads as binary

**Type:** Integration
**Target:** `GET /api/v1/reports/monthly?format=pdf`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Request monthly report (current month) | 200, Content-Type: application/pdf |
| b | Response body is valid PDF | First bytes = `%PDF-` |
| c | Browser triggers file download | Content-Disposition: attachment |
| d | No data for requested month | 200 with empty report (or 204 No Content) |

---

### T-17: CSV report downloads correctly

**Type:** Integration
**Target:** `GET /api/v1/reports/member-list?format=csv`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Request member list CSV | 200, Content-Type: text/csv |
| b | CSV has header row | First line: column names |
| c | 5 seed members in CSV body | 6 lines total (header + 5 data) |
| d | German umlauts preserved | "Müller" renders correctly (UTF-8 or ISO-8859-1) |

---

### T-18: Portal login with session auth

**Type:** Integration
**Target:** `POST /portal/login`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid member credentials (max@example.com / member123) | 200 `{"status":"ok"}` + Set-Cookie session |
| b | Invalid password | 401 `{"error":"Invalid credentials"}` |
| c | Non-member email (admin@...) | 401 (not a member) |
| d | Subsequent /portal/* requests with session cookie | 200 (authenticated) |

---

### T-19: Portal dashboard shows real quota

**Type:** Integration
**Target:** `GET /portal/dashboard`

**Preconditions:**
- Member logged into portal session
- Some distributions recorded for this member

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Fresh member (no distributions) | Quota: 0g used / 50g limit (≥21 years) |
| b | After 10g distribution recorded | Quota: 10g used / 50g limit |
| c | Radial chart shows correct percentage | 20% filled (10/50) |

---

### T-20: Staff list loads for ADMIN

**Type:** Integration
**Target:** `GET /api/v1/staff`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | ADMIN requests staff list | 200 with array of staff accounts |
| b | Includes permissions array per staff | Each item has `permissions: [...]` |
| c | STAFF without MANAGE_STAFF permission | 403 Forbidden |
| d | MEMBER role | 403 Forbidden |

---

### T-21: Staff invite sends email

**Type:** Integration
**Target:** `POST /api/v1/staff/invite`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid email + permissions selection | 201 Created, invite token generated |
| b | Email already registered as staff | 409 Conflict |
| c | Invalid email format | 400 Bad Request |
| d | No permissions selected | 400 Bad Request (at least one required) |

---

### T-22: Permission update persists

**Type:** Integration
**Target:** `PUT /api/v1/staff/{id}/permissions`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Set permissions to [MANAGE_MEMBERS, VIEW_REPORTS] | 200, permissions saved |
| b | Staff can now access members but not stock | GET /members → 200, GET /stock → 403 |
| c | Empty permissions array | 200 (staff has no capabilities, effectively read-only) |
| d | Invalid permission name | 400 Bad Request |

---

### T-23: Staff revoke removes access

**Type:** Integration
**Target:** `DELETE /api/v1/staff/{id}`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Delete staff account | 204 No Content |
| b | Revoked staff can no longer login | 401 on next auth attempt |
| c | Staff disappears from list | GET /staff excludes deleted |
| d | Delete non-existent staff ID | 404 Not Found |

---

### T-24: Non-ADMIN gets 403 on staff endpoints

**Type:** Security
**Target:** SecurityConfig authorization rules

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | STAFF (no MANAGE_STAFF) → GET /staff | 403 |
| b | STAFF (no MANAGE_STAFF) → POST /staff/invite | 403 |
| c | MEMBER → GET /staff | 403 |
| d | Unauthenticated → GET /staff | 401 |
| e | ADMIN → GET /staff | 200 |
| f | STAFF with MANAGE_STAFF → GET /staff | 200 |

---

### T-25: Seed data creates deterministic test state

**Type:** System
**Target:** `R__test_seed.sql`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Backend starts with profile=test-seed | Seed data inserted on startup |
| b | `SELECT COUNT(*) FROM members WHERE club_id='club-001'` | Returns 5 |
| c | `SELECT COUNT(*) FROM batches WHERE club_id='club-001'` | Returns 3 |
| d | Second startup with same profile | No duplicate insert errors (ON CONFLICT DO NOTHING) |
| e | Admin user can login with seed credentials | JWT returned successfully |

---

### T-26: Full E2E: login → distribute → verify quota

**Type:** System
**Class:** `e2e/system-test.spec.ts`

**Preconditions:**
- Full Docker stack running with seed data

**Scenarios:**

| # | Step | Expected Result |
|---|------|-----------------|
| a | Login as admin@gruener-daumen.de | Dashboard loads with KPIs |
| b | Navigate to Members | 5 seed members visible in table |
| c | Navigate to Distributions | Distribution list renders |
| d | Record 10g Amnesia Haze → Max Müller | Success toast, distribution in list |
| e | Navigate to Stock | Amnesia Haze batch shows -10g available |
| f | Navigate to Reports → download monthly PDF | File downloads (200 response) |
| g | Logout, login as member (max@example.com) | Portal dashboard loads |
| h | Portal quota shows 10g used | Radial chart at 20% |

---

### T-27: System test exit code 0 on success

**Type:** System
**Target:** `docker-compose.test.yml`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | All tests pass | Playwright container exits with code 0, compose exits 0 |
| b | A test fails | Playwright exits non-zero, compose exits non-zero |
| c | Backend fails to start | Tests timeout, compose exits non-zero |

---

### T-28: Error state renders when backend down

**Type:** Unit
**Target:** `error-state.tsx`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | API fetch throws network error | Error component visible with message |
| b | Click "Retry" button | Query refetches |
| c | Multiple errors on same page | Each section shows own error independently |

---

### T-29: Loading skeleton appears during fetch

**Type:** Unit
**Target:** `skeleton-card.tsx`, `skeleton-table.tsx`

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Query in `isLoading` state | Skeleton shimmer visible |
| b | Query resolves | Skeleton replaced with real content |
| c | Query in `isFetching` (background refetch) | No skeleton (data still shown) |

---

### T-30: Empty state shown when no data

**Type:** Unit
**Target:** Dashboard, Members, Distributions pages

**Scenarios:**

| # | Input | Expected Result |
|---|-------|-----------------|
| a | Members list empty (API returns []) | "Keine Mitglieder vorhanden" + Add button |
| b | Distributions list empty | "Keine Ausgaben erfasst" message |
| c | Stock empty (no batches) | "Kein Bestand vorhanden" + Add batch button |
| d | Dashboard with 0 members | KPI cards show 0, no chart data placeholder |

---

## Test Data Requirements

| Entity | Count | Source | Notes |
|--------|-------|--------|-------|
| Club | 1 | `R__test_seed.sql` | "Grüner Daumen e.V.", Berlin |
| Admin user | 1 | Seed | admin@gruener-daumen.de / admin123 |
| Staff user | 1 | Seed | staff@gruener-daumen.de / staff123 |
| Members | 5 | Seed | Including 1 under-21 (Jonas Fischer) |
| Strains | 3 | Seed | Amnesia Haze, White Widow, Northern Lights |
| Batches | 3 | Seed | 520g, 430g, 380g available |
| Distributions | 0 | Fresh | Tests create distributions during execution |

---

## Test Coverage Matrix

| Component | Unit | Integration | System | Total |
|-----------|------|-------------|--------|-------|
| Docker/Infra | 0 | 1 | 2 | 3 |
| CORS/Security | 0 | 2 | 0 | 2 |
| API Client | 3 | 0 | 0 | 3 |
| Dashboard | 0 | 1 | 1 | 2 |
| Members | 0 | 2 | 1 | 3 |
| Distributions | 0 | 3 | 1 | 4 |
| Stock | 0 | 2 | 1 | 3 |
| Reports | 0 | 2 | 1 | 3 |
| Portal | 0 | 2 | 1 | 3 |
| Staff | 0 | 5 | 0 | 5 |
| UI States | 3 | 0 | 0 | 3 |
| **Total** | **6** | **20** | **4** | **30** |

---

## Execution Strategy

### Fast feedback loop (during development):
```bash
# Frontend unit tests (Vitest — if configured)
cd cannamanage-frontend && pnpm test

# Backend integration tests (existing Testcontainers suite)
cd cannamanage-api && mvn test

# Existing Playwright E2E (mock backend, ~30s)
cd cannamanage-frontend && pnpm exec playwright test
```

### Full system test (before merge):
```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml up \
  --build --abort-on-container-exit --exit-code-from playwright
```

### Manual smoke test checklist:
1. `docker compose up` → all healthy
2. Open http://localhost:3000 → login page
3. Login as admin → dashboard with real data
4. Add member → appears in list
5. Record distribution → quota updates
6. Download PDF report → valid file
7. Login as member (portal) → see personal quota

---

## References

- Implementation Plan: `docs/sprint-5/cannamanage-sprint5-plan.md` (v1)
- Backend Controllers: `cannamanage-api/src/main/java/de/cannamanage/api/controller/`
- Frontend Types: `cannamanage-frontend/src/types/api.ts`
- Security Config: `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
- Existing E2E: `cannamanage-frontend/e2e/`
