Files
pi_mcps/docs/wiki/pages/CannaManage-08-TestPlan.md
Patrick Plate cda8946c75 docs(cannamanage): add CannaManage wiki pages and mockup images
- 11 wiki pages: CannaManage-Home + 01-10 covering full Phase 0 docs
- 5 mockup images in docs/wiki/images/
- Updated _Sidebar.md with CannaManage section
2026-04-06 11:21:35 +02:00

18 KiB
Raw Permalink Blame History

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