# 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() { ... } ```