diff --git a/08-TestPlan.md b/08-TestPlan.md new file mode 100644 index 0000000..aab27ed --- /dev/null +++ b/08-TestPlan.md @@ -0,0 +1,439 @@ +# 08 — Test Plan + +**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs +**Version:** 0.1.0-PLAN +**Date:** 2026-04-06 +**Status:** Draft + +--- + +## 1. Test Strategy Overview + +### 1.1 Testing Pyramid + +``` + ┌─────────────────┐ + │ E2E Tests │ 10% — Playwright (deferred to v2) + │ (10%) │ + ├─────────────────┤ + │ Integration │ 20% — Spring Boot Test + Testcontainers + │ Tests (20%) │ + ├─────────────────┤ + │ Unit Tests │ 70% — JUnit 5 + Mockito + │ (70%) │ + └─────────────────┘ +``` + +The compliance-critical path (`ComplianceService`) requires **100% line coverage** — no exceptions. Every quota rule is a legal obligation under CanG §§19–22. + +### 1.2 Tools and Frameworks + +| Layer | Tool | Purpose | +|-------|------|---------| +| Unit | JUnit 5 (`junit-jupiter`) | Test runner | +| Unit | Mockito 5 | Mock dependencies | +| Unit | AssertJ | Fluent assertions | +| Integration | Spring Boot Test (`@SpringBootTest`) | Full application context | +| Integration | Testcontainers (PostgreSQL module) | Real DB in Docker | +| Integration | MockMvc / RestAssured | HTTP layer testing | +| Coverage | JaCoCo | Line/branch coverage reporting | +| E2E | Playwright (Java) | Browser automation — **deferred to v2** | + +### 1.3 CI Trigger Policy + +| Branch pattern | Tests run | +|---------------|-----------| +| `feature/*` | Unit tests only (`./mvnw test`) | +| `develop` | Unit + Integration (`./mvnw verify -P integration-tests`) | +| `main` | Unit + Integration + coverage gate | + +Coverage gate blocks merge to `main` if `ComplianceService` drops below 100%. + +--- + +## 2. Unit Test Cases — ComplianceService + +**Class under test:** `de.cannamanage.service.ComplianceService` +**Dependencies mocked:** `DistributionRepository`, `MemberRepository`, `StrainRepository` + +--- + +**TC-001** | `checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly` +- **Given:** Adult member (age 35) with exactly 50.0g distributed this calendar month, requesting 1.0g more +- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)` +- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY` +- **Compliance ref:** CanG §19(2) — 50g/month limit for adults + +--- + +**TC-002** | `checkDistributionAllowed_givenUnder21AtMonthlyLimit_shouldThrowQuotaExceededMonthly` +- **Given:** Under-21 member (age 18) with exactly 30.0g distributed this calendar month, requesting 1.0g more +- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)` +- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY` +- **Compliance ref:** CanG §19(3) — 30g/month limit for under-21 members + +--- + +**TC-003** | `checkDistributionAllowed_givenAdultAtDailyLimit_shouldThrowQuotaExceededDaily` +- **Given:** Adult member with exactly 25.0g distributed today, requesting 0.5g more +- **When:** `complianceService.checkDistributionAllowed(memberId, 0.5)` +- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY` +- **Compliance ref:** CanG §19(2) — 25g/day limit + +--- + +**TC-004** | `checkDistributionAllowed_givenUnder21RequestingHighThcStrain_shouldThrowHighThcRestricted` +- **Given:** Under-21 member (age 20), requesting a strain with THC content 12.5% (exceeds 10% threshold) +- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0, strainId)` +- **Then:** Throws `QuotaExceededException` with code `HIGH_THC_RESTRICTED_UNDER_21` +- **Compliance ref:** CanG §19(4) — under-21 members restricted to ≤10% THC strains + +--- + +**TC-005** | `checkDistributionAllowed_givenAdultAt49gMonthlyRequesting2g_shouldThrowQuotaExceededMonthly` +- **Given:** Adult member with 49.0g distributed this month, requesting 2.0g (would total 51.0g) +- **When:** `complianceService.checkDistributionAllowed(memberId, 2.0)` +- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY` +- **Note:** Even partial over-quota requests must be rejected in full; no partial fulfillment + +--- + +**TC-006** | `checkDistributionAllowed_givenAdultAt0gRequesting25g_shouldReturnAllowed` +- **Given:** Adult member with 0.0g distributed this month and today, requesting 25.0g +- **When:** `complianceService.checkDistributionAllowed(memberId, 25.0)` +- **Then:** Returns `DistributionAllowedResult` with `allowed = true`, `remainingDaily = 0.0`, `remainingMonthly = 25.0` +- **Note:** Exactly at daily limit — allowed + +--- + +**TC-007** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_1g_shouldReturnAllowed` +- **Given:** Adult member with 24.9g distributed today, requesting 0.1g (would reach exactly 25.0g) +- **When:** `complianceService.checkDistributionAllowed(memberId, 0.1)` +- **Then:** Returns `allowed = true`, `remainingDaily = 0.0` +- **Note:** Boundary — exactly at limit is allowed + +--- + +**TC-008** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_2g_shouldThrowQuotaExceededDaily` +- **Given:** Adult member with 24.9g distributed today, requesting 0.2g (would total 25.1g) +- **When:** `complianceService.checkDistributionAllowed(memberId, 0.2)` +- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY` +- **Note:** Boundary + 1 — must be blocked + +--- + +**TC-009** | `checkDistributionAllowed_givenSuspendedMember_shouldThrowMemberInactive` +- **Given:** Member with `status = MemberStatus.SUSPENDED`, requesting any amount +- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)` +- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE` +- **Note:** Status check must occur before any quota calculation + +--- + +**TC-010** | `checkDistributionAllowed_givenExpelledMember_shouldThrowMemberInactive` +- **Given:** Member with `status = MemberStatus.EXPELLED`, requesting any amount +- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0)` +- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE` +- **Note:** Expelled members are permanently blocked, no quota check performed + +--- + +## 3. Unit Test Cases — MemberService + +**Class under test:** `de.cannamanage.service.MemberService` +**Dependencies mocked:** `MemberRepository`, `ClubRepository`, `PasswordEncoder` + +--- + +**TC-011** | `createMember_givenAge17_shouldThrowUnderageException` +- **Given:** `CreateMemberRequest` with DOB resulting in age 17 at time of registration +- **When:** `memberService.createMember(request, tenantId)` +- **Then:** Throws `UnderageException` with message containing minimum age (18) +- **Compliance ref:** CanG §6(1) — membership requires minimum age 18 + +--- + +**TC-012** | ~~`createMember_givenAge18_shouldSucceedAndSetIsUnder21False`~~ — *this case is incorrect* + +> **Note:** Age 18 IS under 21, therefore `isUnder21 = true`. See TC-013. + +--- + +**TC-013** | `createMember_givenAge18_shouldSucceedAndSetIsUnder21True` +- **Given:** `CreateMemberRequest` with DOB resulting in age 18 at time of registration +- **When:** `memberService.createMember(request, tenantId)` +- **Then:** Returns created `Member` with `isUnder21 = true`, `status = ACTIVE` +- **Note:** The 18-year-old flag matters for quota enforcement (30g/month, ≤10% THC) + +--- + +**TC-014** | `createMember_givenAge21_shouldSucceedAndSetIsUnder21False` +- **Given:** `CreateMemberRequest` with DOB resulting in age exactly 21 at time of registration +- **When:** `memberService.createMember(request, tenantId)` +- **Then:** Returns created `Member` with `isUnder21 = false`, `status = ACTIVE` +- **Note:** Age 21 is the boundary — exactly 21 qualifies as adult for quota purposes + +--- + +**TC-015** | `createMember_givenDuplicateEmail_shouldThrowDuplicateMemberException` +- **Given:** `MemberRepository.existsByEmailAndTenantId(email, tenantId)` returns `true` +- **When:** `memberService.createMember(request, tenantId)` +- **Then:** Throws `DuplicateMemberException` with code `DUPLICATE_EMAIL` +- **Note:** Email uniqueness is per-tenant, not global + +--- + +## 4. Unit Test Cases — Tenant Isolation + +**Class under test:** JPA repositories with `@TenantAware` filter active +**Setup:** Thread-local `TenantContext` populated via `TenantContextHolder.setTenantId()` + +--- + +**TC-016** | `distributionRepository_givenTenantAContext_shouldNotReturnTenantBData` +- **Given:** Two tenants (Tenant A: UUID-A, Tenant B: UUID-B); 5 distributions for each; `TenantContextHolder` set to UUID-A +- **When:** `distributionRepository.findAll()` +- **Then:** Returns exactly 5 records, all with `tenantId = UUID-A`; zero records from Tenant B +- **Note:** Hibernate filter `tenantFilter` must be enabled in `TenantAwareInterceptor` + +--- + +**TC-017** | `memberRepository_givenTenantAContext_shouldNotSeeClubBMembers` +- **Given:** 10 members in Club A, 8 members in Club B; context set to Club A's tenant +- **When:** `memberRepository.findAll()` +- **Then:** Returns exactly 10 records; no member from Club B present +- **Note:** Cross-tenant data leakage is a GDPR violation, not just a business bug + +--- + +## 5. Integration Test Cases (Testcontainers) + +**Setup:** `@SpringBootTest(webEnvironment = RANDOM_PORT)` with `@Testcontainers`; real PostgreSQL 16 container; Flyway migrations applied before each test class. + +--- + +**TC-018** | `POST /api/v1/distributions — successful distribution recording` +- **Given:** Active adult member with 0g distributed; valid `DistributionRequest` for 10.0g; authenticated as `ROLE_ADMIN` +- **When:** `POST /api/v1/distributions` with valid JWT +- **Then:** HTTP 201; response body contains `distributionId`, `amount = 10.0`, `recordedAt`; DB contains one `distribution` row with `is_recalled = false` + +--- + +**TC-019** | `POST /api/v1/distributions — quota exceeded returns 422` +- **Given:** Adult member with 50.0g already distributed this month; requesting 1.0g more +- **When:** `POST /api/v1/distributions` +- **Then:** HTTP 422; response body `{"errorCode": "QUOTA_EXCEEDED_MONTHLY", "remainingMonthly": 0.0}` + +--- + +**TC-020** | `POST /api/v1/distributions — concurrent race condition (last gram)` +- **Given:** Adult member with 24.9g distributed today; two concurrent requests each for 0.2g (both would individually exceed 25g/day) +- **When:** Both requests fired simultaneously via two threads +- **Then:** Exactly one succeeds (HTTP 201); exactly one fails (HTTP 422 `QUOTA_EXCEEDED_DAILY`); DB total does not exceed 25.0g +- **Mechanism:** `SELECT ... FOR UPDATE` on quota aggregation query prevents double-spend + +--- + +**TC-021** | `POST /api/v1/auth/login — valid credentials return JWT` +- **Given:** Admin user with email `admin@test-club.de`, correct password +- **When:** `POST /api/v1/auth/login` with `{"email": "admin@test-club.de", "password": "..."}` +- **Then:** HTTP 200; response contains `accessToken` (JWT), `tokenType = "Bearer"`, `expiresIn = 3600` + +--- + +**TC-022** | `POST /api/v1/auth/login — invalid credentials return 401` +- **Given:** Admin user exists; wrong password provided +- **When:** `POST /api/v1/auth/login` with wrong password +- **Then:** HTTP 401; response `{"errorCode": "INVALID_CREDENTIALS"}`; no token issued + +--- + +**TC-023** | `GET /api/v1/members — ROLE_MEMBER accessing admin endpoint returns 403` +- **Given:** Authenticated user with `ROLE_MEMBER` JWT (not ROLE_ADMIN) +- **When:** `GET /api/v1/members` (admin-only endpoint) +- **Then:** HTTP 403; response `{"errorCode": "FORBIDDEN"}` + +--- + +**TC-024** | `GET /api/v1/members/{id}/quota — member accessing own quota returns 200` +- **Given:** Authenticated member with JWT; requesting their own `memberId` +- **When:** `GET /api/v1/members/{ownId}/quota` +- **Then:** HTTP 200; response contains `dailyUsed`, `dailyRemaining`, `monthlyUsed`, `monthlyRemaining`, `isUnder21` + +--- + +**TC-025** | `GET /api/v1/members/{otherMemberId}/quota — member accessing other member returns 403` +- **Given:** Authenticated member requesting quota of a *different* member (same club) +- **When:** `GET /api/v1/members/{otherMemberId}/quota` +- **Then:** HTTP 403; GDPR principle: members must not see each other's consumption data + +--- + +**TC-026** | `POST /api/v1/stock/batches/{id}/recall — verify cascade` +- **Given:** Batch `BATCH-TEST-001` with 3 distributions recorded against it; `isRecalled = false` +- **When:** `POST /api/v1/stock/batches/BATCH-TEST-001/recall` with `{"reason": "Contamination detected"}` +- **Then:** HTTP 200; batch `isRecalled = true`; all 3 distribution records have `isRecalled = true`; response body contains list of 3 affected member IDs for notification + +--- + +## 6. Test Data Fixtures + +Define these constants in `src/test/java/de/cannamanage/fixtures/TestFixtures.java`: + +```java +public final class TestFixtures { + + // Tenant + public static final UUID TENANT_ID = + UUID.fromString("00000000-0000-0000-0000-000000000001"); + public static final String CLUB_NAME = "Test Cannabis Club e.V."; + + // Adult member + public static final UUID ADULT_MEMBER_ID = + UUID.fromString("00000000-0000-0000-0000-000000000010"); + public static final String ADULT_MEMBER_NAME = "Klaus Mueller"; + public static final LocalDate ADULT_MEMBER_DOB = + LocalDate.of(1990, 1, 1); // age 36 as of 2026 + + // Under-21 member + public static final UUID UNDER21_MEMBER_ID = + UUID.fromString("00000000-0000-0000-0000-000000000011"); + public static final String UNDER21_MEMBER_NAME = "Lisa Mayer"; + public static final LocalDate UNDER21_MEMBER_DOB = + LocalDate.of(2007, 1, 1); // age 19 as of 2026, isUnder21=true + + // Strain + public static final UUID STRAIN_ID = + UUID.fromString("00000000-0000-0000-0000-000000000020"); + public static final String STRAIN_NAME = "Test OG"; + public static final double STRAIN_THC_PERCENT = 20.0; + public static final double STRAIN_CBD_PERCENT = 1.0; + + // Batch + public static final String BATCH_NUMBER = "BATCH-TEST-001"; + public static final double BATCH_INITIAL_WEIGHT_G = 500.0; + + // Compliance constants (mirror ComplianceConstants.java) + public static final double ADULT_MONTHLY_LIMIT_G = 50.0; + public static final double UNDER21_MONTHLY_LIMIT_G = 30.0; + public static final double DAILY_LIMIT_G = 25.0; + public static final double UNDER21_MAX_THC_PERCENT = 10.0; +} +``` + +--- + +## 7. Coverage Requirements + +| Module | Test Type | Minimum Coverage | Enforcement | +|--------|-----------|-----------------|-------------| +| `cannamanage-service` | Unit | 80% line | JaCoCo CI gate | +| `cannamanage-api` | Integration | 70% endpoint coverage | Manual checklist | +| `cannamanage-domain` | Unit | 60% line (entities/enums) | JaCoCo CI gate | +| `ComplianceService` | Unit | **100% line + branch** | JaCoCo CI gate — hard fail | +| `TenantIsolationFilter` | Unit + Integration | 90% line | JaCoCo CI gate | + +> **Rationale for 100% on ComplianceService:** Each uncovered line is a potential legal violation. A missed branch in quota calculation could result in over-distribution — a criminal offence under CanG §34. This is not negotiable. + +### JaCoCo Configuration (`pom.xml`) + +```xml + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + jacoco-check + check + + + + CLASS + + de.cannamanage.service.ComplianceService + + + + LINE + COVEREDRATIO + 1.00 + + + BRANCH + COVEREDRATIO + 1.00 + + + + + PACKAGE + + de.cannamanage.service.* + + + + LINE + COVEREDRATIO + 0.80 + + + + + + + + +``` + +--- + +## 8. Test Execution + +```bash +# Run all unit tests +./mvnw test -pl cannamanage-service + +# Run integration tests (requires Docker for Testcontainers) +./mvnw verify -P integration-tests + +# Run specific test class +./mvnw test -pl cannamanage-service -Dtest=ComplianceServiceTest + +# Coverage report (output: target/site/jacoco/index.html) +./mvnw verify jacoco:report + +# Coverage report for single module +./mvnw verify jacoco:report -pl cannamanage-service + +# Run compliance tests only (tagged) +./mvnw test -pl cannamanage-service -Dgroups=compliance + +# Check coverage gate (will fail build if thresholds not met) +./mvnw verify -P coverage-check +``` + +### Testcontainers Docker requirement + +Integration tests require Docker available on the host. The PostgreSQL container is pulled automatically by Testcontainers on first run. Ensure: +- Docker daemon running: `systemctl start docker` (or `docker info`) +- User in `docker` group: `sudo usermod -aG docker $USER` + +### Test annotation conventions + +```java +// Unit test — no Spring context +@ExtendWith(MockitoExtension.class) +class ComplianceServiceTest { ... } + +// Integration test — full context + Testcontainers +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +class DistributionIntegrationTest { ... } + +// Tag compliance tests for selective execution +@Tag("compliance") +@Test +void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... } +```