1
CannaManage 08 TestPlan
Patrick Plate edited this page 2026-04-06 11:21:47 +02:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_shouldSucceedAndSetIsUnder21Falsethis 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:

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 (or docker info)
  • User in docker group: 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() { ... }