wiki: add 08 TestPlan

pplate
2026-06-11 11:41:48 +00:00
parent bf2448cd55
commit caf881a59a
+439
@@ -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 §§1922.
### 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() { ... }
```