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
QuotaExceededExceptionwith codeQUOTA_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
QuotaExceededExceptionwith codeQUOTA_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
QuotaExceededExceptionwith codeQUOTA_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
QuotaExceededExceptionwith codeHIGH_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
QuotaExceededExceptionwith codeQUOTA_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
DistributionAllowedResultwithallowed = 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
QuotaExceededExceptionwith codeQUOTA_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
QuotaExceededExceptionwith codeMEMBER_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
QuotaExceededExceptionwith codeMEMBER_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:
CreateMemberRequestwith DOB resulting in age 17 at time of registration - When:
memberService.createMember(request, tenantId) - Then: Throws
UnderageExceptionwith message containing minimum age (18) - Compliance ref: CanG §6(1) — membership requires minimum age 18
TC-012 | — this case is incorrectcreateMember_givenAge18_shouldSucceedAndSetIsUnder21False
Note: Age 18 IS under 21, therefore
isUnder21 = true. See TC-013.
TC-013 | createMember_givenAge18_shouldSucceedAndSetIsUnder21True
- Given:
CreateMemberRequestwith DOB resulting in age 18 at time of registration - When:
memberService.createMember(request, tenantId) - Then: Returns created
MemberwithisUnder21 = true,status = ACTIVE - Note: The 18-year-old flag matters for quota enforcement (30g/month, ≤10% THC)
TC-014 | createMember_givenAge21_shouldSucceedAndSetIsUnder21False
- Given:
CreateMemberRequestwith DOB resulting in age exactly 21 at time of registration - When:
memberService.createMember(request, tenantId) - Then: Returns created
MemberwithisUnder21 = 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)returnstrue - When:
memberService.createMember(request, tenantId) - Then: Throws
DuplicateMemberExceptionwith codeDUPLICATE_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;
TenantContextHolderset to UUID-A - When:
distributionRepository.findAll() - Then: Returns exactly 5 records, all with
tenantId = UUID-A; zero records from Tenant B - Note: Hibernate filter
tenantFiltermust be enabled inTenantAwareInterceptor
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
DistributionRequestfor 10.0g; authenticated asROLE_ADMIN - When:
POST /api/v1/distributionswith valid JWT - Then: HTTP 201; response body contains
distributionId,amount = 10.0,recordedAt; DB contains onedistributionrow withis_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 UPDATEon 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/loginwith{"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/loginwith 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_MEMBERJWT (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-001with 3 distributions recorded against it;isRecalled = false - When:
POST /api/v1/stock/batches/BATCH-TEST-001/recallwith{"reason": "Contamination detected"} - Then: HTTP 200; batch
isRecalled = true; all 3 distribution records haveisRecalled = 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:
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)
<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
# 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(ordocker info) - User in
dockergroup:sudo usermod -aG docker $USER
Test annotation conventions
// 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() { ... }
🔧 pi_mcps Wiki
Overview
MCP Servers
Java Projects
🌿 CannaManage
- 🏠 Overview
- 📋 Project Charter
- 📖 User Stories
- 🏗️ Architecture
- 🔄 Flow Charts
- 🔌 API Spec
- 🎨 Wireframes
- 📏 Coding Standards
- 🧪 Test Plan
- 🚀 Deployment
- 🔍 Retrospective