# 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/`