# 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
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <id>jacoco-check</id>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <element>CLASS</element>
                        <includes>
                            <include>de.cannamanage.service.ComplianceService</include>
                        </includes>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>1.00</minimum>
                            </limit>
                            <limit>
                                <counter>BRANCH</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>1.00</minimum>
                            </limit>
                        </limits>
                    </rule>
                    <rule>
                        <element>PACKAGE</element>
                        <includes>
                            <include>de.cannamanage.service.*</include>
                        </includes>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>
```

---

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