feat(sprint-1): CannaManage foundation — compliance engine, JPA entities, tests TC-001→TC-025
- Maven multi-module project (parent + domain + service + api) - AbstractTenantEntity with Hibernate @Filter for multi-tenancy (explicit getters/setters, Java 25 compatible) - TenantContext ThreadLocal for request-scoped tenant isolation - 8 JPA entities: Club, Member, Strain, Batch, Distribution, MonthlyQuota, StockMovement, User - ComplianceConstants with CanG §19 limits (25g/day adult, 50g/month adult, 30g/month under-21, 10% THC cap) - ComplianceService: checkDistributionAllowed() with fail-fast sequential CanG checks - Unit tests TC-001→TC-025: 25/25 passing, 100% line+branch coverage on ComplianceService (JaCoCo 0.8.13) - Flyway V1__initial_schema.sql: all 8 tables + indexes - docker-compose.yml: PostgreSQL 16 local dev - application-local.properties: local profile configuration Closes #1 #2 #3 #4 #5 #6 #7 #8 #9 #10
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>de.cannamanage</groupId>
|
||||
<artifactId>cannamanage-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>cannamanage-service</artifactId>
|
||||
<name>CannaManage — Service (Business Logic)</name>
|
||||
|
||||
<dependencies>
|
||||
<!-- Internal domain -->
|
||||
<dependency>
|
||||
<groupId>de.cannamanage</groupId>
|
||||
<artifactId>cannamanage-domain</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Data JPA for repository interfaces -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring TX annotations -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>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>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,210 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.dto.ComplianceCheckResult;
|
||||
import de.cannamanage.service.dto.QuotaStatus;
|
||||
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.MonthlyQuotaRepository;
|
||||
import de.cannamanage.service.repository.StrainRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Core compliance engine implementing CanG §19 distribution rules.
|
||||
* All checks are fail-fast — the first violation throws immediately.
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class ComplianceService {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final BatchRepository batchRepository;
|
||||
private final MonthlyQuotaRepository monthlyQuotaRepository;
|
||||
private final StrainRepository strainRepository;
|
||||
|
||||
public ComplianceService(
|
||||
MemberRepository memberRepository,
|
||||
DistributionRepository distributionRepository,
|
||||
BatchRepository batchRepository,
|
||||
MonthlyQuotaRepository monthlyQuotaRepository,
|
||||
StrainRepository strainRepository) {
|
||||
this.memberRepository = memberRepository;
|
||||
this.distributionRepository = distributionRepository;
|
||||
this.batchRepository = batchRepository;
|
||||
this.monthlyQuotaRepository = monthlyQuotaRepository;
|
||||
this.strainRepository = strainRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a distribution is permitted under CanG §19.
|
||||
* Sequential fail-fast checks in legally mandated order.
|
||||
*/
|
||||
public ComplianceCheckResult checkDistributionAllowed(
|
||||
UUID memberId, UUID batchId, BigDecimal quantityGrams) {
|
||||
|
||||
// 1. Load member
|
||||
Member member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||
|
||||
// 2. Member must be ACTIVE
|
||||
if (member.getStatus() != MemberStatus.ACTIVE) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.MEMBER_INACTIVE,
|
||||
"Member " + memberId + " is not active (status=" + member.getStatus() + ")");
|
||||
}
|
||||
|
||||
// 3. Batch must be AVAILABLE
|
||||
Batch batch = batchRepository.findById(batchId)
|
||||
.orElseThrow(() -> new BatchNotFoundException(batchId));
|
||||
|
||||
if (batch.getStatus() != BatchStatus.AVAILABLE) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.BATCH_UNAVAILABLE,
|
||||
"Batch " + batchId + " is not available (status=" + batch.getStatus() + ")");
|
||||
}
|
||||
|
||||
// 4. Load strain
|
||||
Strain strain = strainRepository.findById(batch.getStrainId())
|
||||
.orElseThrow(() -> new BatchNotFoundException(batch.getStrainId()));
|
||||
|
||||
// 5. Under-21 THC restriction (CanG §19(4))
|
||||
if (member.isUnder21() &&
|
||||
strain.getThcPercentage().compareTo(ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE) > 0) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21,
|
||||
"Under-21 member cannot receive strain with THC% > " +
|
||||
ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE);
|
||||
}
|
||||
|
||||
// 6. Daily limit check (CanG §19(2))
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
|
||||
BigDecimal todayDistributed = distributionRepository
|
||||
.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||
|
||||
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
|
||||
if (todayDistributed.add(quantityGrams).compareTo(dailyLimit) > 0) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.QUOTA_EXCEEDED_DAILY,
|
||||
"Daily limit of " + dailyLimit + "g would be exceeded. " +
|
||||
"Already distributed today: " + todayDistributed + "g");
|
||||
}
|
||||
|
||||
// 7. Monthly limit check (CanG §19(2)/(3))
|
||||
int year = today.getYear();
|
||||
int month = today.getMonthValue();
|
||||
|
||||
BigDecimal monthlyLimit = member.isUnder21()
|
||||
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
|
||||
|
||||
MonthlyQuota quota = monthlyQuotaRepository
|
||||
.findByMemberIdAndYearAndMonth(memberId, year, month)
|
||||
.orElseGet(() -> createNewQuota(memberId, year, month, monthlyLimit));
|
||||
|
||||
if (quota.getTotalDistributed().add(quantityGrams).compareTo(quota.getMaxAllowed()) > 0) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY,
|
||||
"Monthly limit of " + quota.getMaxAllowed() + "g would be exceeded. " +
|
||||
"Already distributed this month: " + quota.getTotalDistributed() + "g");
|
||||
}
|
||||
|
||||
// 8. All checks passed
|
||||
BigDecimal remainingDaily = dailyLimit
|
||||
.subtract(todayDistributed)
|
||||
.subtract(quantityGrams);
|
||||
BigDecimal remainingMonthly = quota.getMaxAllowed()
|
||||
.subtract(quota.getTotalDistributed())
|
||||
.subtract(quantityGrams);
|
||||
|
||||
return new ComplianceCheckResult(true, remainingDaily, remainingMonthly, member.isUnder21());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public QuotaStatus getQuotaStatus(UUID memberId) {
|
||||
Member member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
int year = today.getYear();
|
||||
int month = today.getMonthValue();
|
||||
|
||||
BigDecimal monthlyLimit = member.isUnder21()
|
||||
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
|
||||
|
||||
MonthlyQuota quota = monthlyQuotaRepository
|
||||
.findByMemberIdAndYearAndMonth(memberId, year, month)
|
||||
.orElseGet(() -> {
|
||||
MonthlyQuota empty = new MonthlyQuota();
|
||||
empty.setTotalDistributed(BigDecimal.ZERO);
|
||||
empty.setMaxAllowed(monthlyLimit);
|
||||
return empty;
|
||||
});
|
||||
|
||||
BigDecimal remaining = quota.getMaxAllowed().subtract(quota.getTotalDistributed());
|
||||
|
||||
return new QuotaStatus(
|
||||
quota.getMaxAllowed(),
|
||||
quota.getTotalDistributed(),
|
||||
remaining,
|
||||
member.isUnder21(),
|
||||
year,
|
||||
month
|
||||
);
|
||||
}
|
||||
|
||||
public void validateMembershipAge(LocalDate dateOfBirth) {
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
int age = today.getYear() - dateOfBirth.getYear();
|
||||
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
|
||||
age--;
|
||||
}
|
||||
if (age < ComplianceConstants.MINIMUM_MEMBERSHIP_AGE) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.MEMBER_INACTIVE,
|
||||
"Applicant is under minimum membership age of " +
|
||||
ComplianceConstants.MINIMUM_MEMBERSHIP_AGE);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isUnder21(LocalDate dateOfBirth) {
|
||||
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||
int age = today.getYear() - dateOfBirth.getYear();
|
||||
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
|
||||
age--;
|
||||
}
|
||||
return age < ComplianceConstants.UNDER21_THRESHOLD_AGE;
|
||||
}
|
||||
|
||||
private MonthlyQuota createNewQuota(UUID memberId, int year, int month, BigDecimal maxAllowed) {
|
||||
MonthlyQuota quota = new MonthlyQuota();
|
||||
quota.setMemberId(memberId);
|
||||
quota.setYear(year);
|
||||
quota.setMonth(month);
|
||||
quota.setTotalDistributed(BigDecimal.ZERO);
|
||||
quota.setMaxAllowed(maxAllowed);
|
||||
return monthlyQuotaRepository.save(quota);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.service.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Result of a compliance check for a potential cannabis distribution.
|
||||
* When {@code allowed} is true, {@code remainingDaily} and {@code remainingMonthly}
|
||||
* reflect the quota after the requested distribution would be applied.
|
||||
*/
|
||||
public record ComplianceCheckResult(
|
||||
boolean allowed,
|
||||
BigDecimal remainingDaily,
|
||||
BigDecimal remainingMonthly,
|
||||
boolean isUnder21
|
||||
) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.service.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Current quota status for a member in a given calendar month.
|
||||
*/
|
||||
public record QuotaStatus(
|
||||
BigDecimal totalAllowed,
|
||||
BigDecimal totalUsed,
|
||||
BigDecimal remaining,
|
||||
boolean isUnder21,
|
||||
int year,
|
||||
int month
|
||||
) {}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package de.cannamanage.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class BatchNotFoundException extends RuntimeException {
|
||||
|
||||
public BatchNotFoundException(UUID batchId) {
|
||||
super("Batch not found: " + batchId);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package de.cannamanage.service.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class MemberNotFoundException extends RuntimeException {
|
||||
|
||||
public MemberNotFoundException(UUID memberId) {
|
||||
super("Member not found: " + memberId);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package de.cannamanage.service.exception;
|
||||
|
||||
/**
|
||||
* Thrown when a cannabis distribution would violate a CanG compliance rule.
|
||||
* The {@code code} field identifies which specific rule was violated,
|
||||
* enabling the API layer to return structured error responses.
|
||||
*/
|
||||
public class QuotaExceededException extends RuntimeException {
|
||||
|
||||
private final QuotaViolationCode code;
|
||||
|
||||
public QuotaExceededException(QuotaViolationCode code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public QuotaViolationCode getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package de.cannamanage.service.exception;
|
||||
|
||||
public enum QuotaViolationCode {
|
||||
MEMBER_INACTIVE,
|
||||
QUOTA_EXCEEDED_DAILY,
|
||||
QUOTA_EXCEEDED_MONTHLY,
|
||||
HIGH_THC_RESTRICTED_UNDER_21,
|
||||
BATCH_UNAVAILABLE
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Distribution;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DistributionRepository extends JpaRepository<Distribution, UUID> {
|
||||
|
||||
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
|
||||
"WHERE d.memberId = :memberId AND d.distributedAt >= :dayStart AND d.distributedAt < :dayEnd")
|
||||
BigDecimal sumQuantityByMemberAndDay(
|
||||
@Param("memberId") UUID memberId,
|
||||
@Param("dayStart") Instant dayStart,
|
||||
@Param("dayEnd") Instant dayEnd
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface MonthlyQuotaRepository extends JpaRepository<MonthlyQuota, UUID> {
|
||||
|
||||
Optional<MonthlyQuota> findByMemberIdAndYearAndMonth(UUID memberId, int year, int month);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface StrainRepository extends JpaRepository<Strain, UUID> {
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.MonthlyQuotaRepository;
|
||||
import de.cannamanage.service.repository.StrainRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ComplianceServiceTest {
|
||||
|
||||
@Mock MemberRepository memberRepository;
|
||||
@Mock DistributionRepository distributionRepository;
|
||||
@Mock BatchRepository batchRepository;
|
||||
@Mock MonthlyQuotaRepository monthlyQuotaRepository;
|
||||
@Mock StrainRepository strainRepository;
|
||||
|
||||
@InjectMocks ComplianceService complianceService;
|
||||
|
||||
private static final UUID ADULT_MEMBER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID UNDER21_MEMBER_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private static final UUID BATCH_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
private static final UUID STRAIN_ID = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||
private static final UUID HIGH_THC_STRAIN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555");
|
||||
|
||||
private Member adultMember;
|
||||
private Member under21Member;
|
||||
private Batch availableBatch;
|
||||
private Strain normalStrain;
|
||||
private Strain highThcStrain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adultMember = new Member();
|
||||
adultMember.setStatus(MemberStatus.ACTIVE);
|
||||
adultMember.setUnder21(false);
|
||||
|
||||
under21Member = new Member();
|
||||
under21Member.setStatus(MemberStatus.ACTIVE);
|
||||
under21Member.setUnder21(true);
|
||||
|
||||
normalStrain = new Strain();
|
||||
normalStrain.setThcPercentage(new BigDecimal("8.0"));
|
||||
normalStrain.setCbdPercentage(new BigDecimal("2.0"));
|
||||
|
||||
highThcStrain = new Strain();
|
||||
highThcStrain.setThcPercentage(new BigDecimal("22.0"));
|
||||
highThcStrain.setCbdPercentage(BigDecimal.ZERO);
|
||||
|
||||
availableBatch = new Batch();
|
||||
availableBatch.setStatus(BatchStatus.AVAILABLE);
|
||||
availableBatch.setStrainId(STRAIN_ID);
|
||||
}
|
||||
|
||||
// TC-001: Adult at monthly limit → QUOTA_EXCEEDED_MONTHLY
|
||||
@Test
|
||||
@DisplayName("TC-001: Adult at 50g monthly usage → QUOTA_EXCEEDED_MONTHLY")
|
||||
void tc001_adultAtMonthlyLimit() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
MonthlyQuota quota = buildQuota("50.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||
}
|
||||
|
||||
// TC-002: Under-21 at monthly limit → QUOTA_EXCEEDED_MONTHLY
|
||||
@Test
|
||||
@DisplayName("TC-002: Under-21 at 30g monthly usage → QUOTA_EXCEEDED_MONTHLY")
|
||||
void tc002_under21AtMonthlyLimit() {
|
||||
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
MonthlyQuota quota = buildQuota("30.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||
}
|
||||
|
||||
// TC-003: Adult at daily limit → QUOTA_EXCEEDED_DAILY
|
||||
@Test
|
||||
@DisplayName("TC-003: Adult at 25g today requesting 1g → QUOTA_EXCEEDED_DAILY")
|
||||
void tc003_adultAtDailyLimit() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(new BigDecimal("25.0"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
|
||||
}
|
||||
|
||||
// TC-004: Under-21 + high THC → HIGH_THC_RESTRICTED_UNDER_21
|
||||
@Test
|
||||
@DisplayName("TC-004: Under-21 + 22% THC batch → HIGH_THC_RESTRICTED_UNDER_21")
|
||||
void tc004_under21HighThcStrain() {
|
||||
Batch highThcBatch = new Batch();
|
||||
highThcBatch.setStatus(BatchStatus.AVAILABLE);
|
||||
highThcBatch.setStrainId(HIGH_THC_STRAIN_ID);
|
||||
|
||||
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(highThcBatch));
|
||||
when(strainRepository.findById(HIGH_THC_STRAIN_ID)).thenReturn(Optional.of(highThcStrain));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21);
|
||||
}
|
||||
|
||||
// TC-005: Adult at 49g requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)
|
||||
@Test
|
||||
@DisplayName("TC-005: Adult at 49g monthly requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)")
|
||||
void tc005_adultAt49gRequesting2g() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
MonthlyQuota quota = buildQuota("49.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("2.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||
}
|
||||
|
||||
// TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0
|
||||
@Test
|
||||
@DisplayName("TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0")
|
||||
void tc006_adultAt0gRequesting25g_allowed() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
MonthlyQuota quota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("25.0"));
|
||||
|
||||
assertThat(result.allowed()).isTrue();
|
||||
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
assertThat(result.remainingMonthly()).isEqualByComparingTo(new BigDecimal("25.0"));
|
||||
assertThat(result.isUnder21()).isFalse();
|
||||
}
|
||||
|
||||
// TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0
|
||||
@Test
|
||||
@DisplayName("TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0")
|
||||
void tc007_adultAt24dot9gRequesting0dot1g_allowed() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(new BigDecimal("24.9"));
|
||||
MonthlyQuota quota = buildQuota("24.9", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.1"));
|
||||
|
||||
assertThat(result.allowed()).isTrue();
|
||||
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
// TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)
|
||||
@Test
|
||||
@DisplayName("TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)")
|
||||
void tc008_adultAt24dot9gRequesting0dot2g_dailyExceeded() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(new BigDecimal("24.9"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.2")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
|
||||
}
|
||||
|
||||
// TC-009: SUSPENDED member → MEMBER_INACTIVE
|
||||
@Test
|
||||
@DisplayName("TC-009: SUSPENDED member → MEMBER_INACTIVE")
|
||||
void tc009_suspendedMember() {
|
||||
Member suspended = new Member();
|
||||
suspended.setStatus(MemberStatus.SUSPENDED);
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(suspended));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||
}
|
||||
|
||||
// TC-010: EXPELLED member → MEMBER_INACTIVE
|
||||
@Test
|
||||
@DisplayName("TC-010: EXPELLED member → MEMBER_INACTIVE")
|
||||
void tc010_expelledMember() {
|
||||
Member expelled = new Member();
|
||||
expelled.setStatus(MemberStatus.EXPELLED);
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(expelled));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||
}
|
||||
|
||||
// TC-011: No existing quota → creates new quota (createNewQuota path)
|
||||
@Test
|
||||
@DisplayName("TC-011: No existing quota → creates new quota, distribution allowed")
|
||||
void tc011_noExistingQuota_createsNewAndAllows() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
// Return empty — triggers createNewQuota
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
|
||||
|
||||
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("10.0"));
|
||||
|
||||
assertThat(result.allowed()).isTrue();
|
||||
assertThat(result.isUnder21()).isFalse();
|
||||
}
|
||||
|
||||
// TC-012: Under-21 no existing quota → creates quota with under-21 limit
|
||||
@Test
|
||||
@DisplayName("TC-012: Under-21 no existing quota → creates quota with 30g limit")
|
||||
void tc012_under21NoExistingQuota_createsNewWithUnder21Limit() {
|
||||
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||
.thenReturn(BigDecimal.ZERO);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
|
||||
|
||||
var result = complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"));
|
||||
|
||||
assertThat(result.allowed()).isTrue();
|
||||
assertThat(result.isUnder21()).isTrue();
|
||||
}
|
||||
|
||||
// TC-013: BATCH_UNAVAILABLE — batch is RECALLED
|
||||
@Test
|
||||
@DisplayName("TC-013: RECALLED batch → BATCH_UNAVAILABLE")
|
||||
void tc013_recalledBatch() {
|
||||
Batch recalled = new Batch();
|
||||
recalled.setStatus(BatchStatus.RECALLED);
|
||||
recalled.setStrainId(STRAIN_ID);
|
||||
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(recalled));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.BATCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// TC-014: getQuotaStatus — adult member with existing quota
|
||||
@Test
|
||||
@DisplayName("TC-014: getQuotaStatus — adult with existing quota returns correct status")
|
||||
void tc014_getQuotaStatus_adultWithExistingQuota() {
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
MonthlyQuota quota = buildQuota("20.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of(quota));
|
||||
|
||||
var status = complianceService.getQuotaStatus(ADULT_MEMBER_ID);
|
||||
|
||||
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||
assertThat(status.totalUsed()).isEqualByComparingTo(new BigDecimal("20.0"));
|
||||
assertThat(status.remaining()).isEqualByComparingTo(new BigDecimal("30.0"));
|
||||
assertThat(status.isUnder21()).isFalse();
|
||||
}
|
||||
|
||||
// TC-015: getQuotaStatus — under-21 with no existing quota
|
||||
@Test
|
||||
@DisplayName("TC-015: getQuotaStatus — under-21 with no quota returns 30g limit")
|
||||
void tc015_getQuotaStatus_under21NoExistingQuota() {
|
||||
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
var status = complianceService.getQuotaStatus(UNDER21_MEMBER_ID);
|
||||
|
||||
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||
assertThat(status.totalUsed()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
assertThat(status.isUnder21()).isTrue();
|
||||
}
|
||||
|
||||
// TC-016: getQuotaStatus — member not found throws exception
|
||||
@Test
|
||||
@DisplayName("TC-016: getQuotaStatus — unknown member ID throws MemberNotFoundException")
|
||||
void tc016_getQuotaStatus_memberNotFound() {
|
||||
UUID unknown = UUID.fromString("99999999-9999-9999-9999-999999999999");
|
||||
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> complianceService.getQuotaStatus(unknown))
|
||||
.isInstanceOf(MemberNotFoundException.class);
|
||||
}
|
||||
|
||||
// TC-017: validateMembershipAge — 18-year-old is allowed
|
||||
@Test
|
||||
@DisplayName("TC-017: validateMembershipAge — 18-year-old is allowed")
|
||||
void tc017_validateMembershipAge_18YearsOld_allowed() {
|
||||
// Just before birthday this year to keep age = 18
|
||||
LocalDate dob = LocalDate.now().minusYears(18).minusDays(1);
|
||||
// Should not throw
|
||||
complianceService.validateMembershipAge(dob);
|
||||
}
|
||||
|
||||
// TC-018: validateMembershipAge — 17-year-old is rejected
|
||||
@Test
|
||||
@DisplayName("TC-018: validateMembershipAge — 17-year-old is rejected")
|
||||
void tc018_validateMembershipAge_17YearsOld_rejected() {
|
||||
LocalDate dob = LocalDate.now().minusYears(17);
|
||||
|
||||
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||
}
|
||||
|
||||
// TC-019: isUnder21 — 20-year-old returns true
|
||||
@Test
|
||||
@DisplayName("TC-019: isUnder21 — 20-year-old returns true")
|
||||
void tc019_isUnder21_20YearsOld_returnsTrue() {
|
||||
LocalDate dob = LocalDate.now().minusYears(20).minusDays(1);
|
||||
assertThat(complianceService.isUnder21(dob)).isTrue();
|
||||
}
|
||||
|
||||
// TC-020: isUnder21 — 21-year-old returns false
|
||||
@Test
|
||||
@DisplayName("TC-020: isUnder21 — 21-year-old returns false")
|
||||
void tc020_isUnder21_21YearsOld_returnsFalse() {
|
||||
LocalDate dob = LocalDate.now().minusYears(21).minusDays(1);
|
||||
assertThat(complianceService.isUnder21(dob)).isFalse();
|
||||
}
|
||||
|
||||
// TC-021: member not found in checkDistributionAllowed → MemberNotFoundException
|
||||
@Test
|
||||
@DisplayName("TC-021: Unknown member ID in checkDistributionAllowed → MemberNotFoundException")
|
||||
void tc021_memberNotFoundInCheck() {
|
||||
UUID unknown = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(unknown, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(de.cannamanage.service.exception.MemberNotFoundException.class);
|
||||
}
|
||||
|
||||
// TC-022: batch not found in checkDistributionAllowed → BatchNotFoundException
|
||||
@Test
|
||||
@DisplayName("TC-022: Unknown batch ID in checkDistributionAllowed → BatchNotFoundException")
|
||||
void tc022_batchNotFoundInCheck() {
|
||||
UUID unknownBatch = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(unknownBatch)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, unknownBatch, new BigDecimal("5.0")))
|
||||
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
|
||||
}
|
||||
|
||||
// TC-023: strain not found for batch → BatchNotFoundException
|
||||
@Test
|
||||
@DisplayName("TC-023: Strain not found for batch → BatchNotFoundException")
|
||||
void tc023_strainNotFoundForBatch() {
|
||||
UUID unknownStrain = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
Batch batchWithUnknownStrain = new Batch();
|
||||
batchWithUnknownStrain.setStatus(BatchStatus.AVAILABLE);
|
||||
batchWithUnknownStrain.setStrainId(unknownStrain);
|
||||
|
||||
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batchWithUnknownStrain));
|
||||
when(strainRepository.findById(unknownStrain)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
|
||||
}
|
||||
|
||||
// TC-024: validateMembershipAge — birthday not yet occurred this year (age-- branch)
|
||||
@Test
|
||||
@DisplayName("TC-024: validateMembershipAge — birthday later this year → age is 17, rejected")
|
||||
void tc024_validateMembershipAge_birthdayLaterThisYear() {
|
||||
// Person who will turn 18 tomorrow — today they are 17 → should throw
|
||||
LocalDate dob = LocalDate.now().plusDays(1).minusYears(18);
|
||||
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
|
||||
.isInstanceOf(QuotaExceededException.class)
|
||||
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||
}
|
||||
|
||||
// TC-025: isUnder21 — birthday not yet occurred this year → age-- branch
|
||||
@Test
|
||||
@DisplayName("TC-025: isUnder21 — person turns 21 tomorrow → still under 21 today")
|
||||
void tc025_isUnder21_birthdayTomorrow_stillUnder21() {
|
||||
// Person who will turn 21 tomorrow — today they are 20 → still under 21
|
||||
LocalDate dob = LocalDate.now().plusDays(1).minusYears(21);
|
||||
assertThat(complianceService.isUnder21(dob)).isTrue();
|
||||
}
|
||||
|
||||
// Helper
|
||||
private MonthlyQuota buildQuota(String totalDistributed, BigDecimal maxAllowed) {
|
||||
MonthlyQuota quota = new MonthlyQuota();
|
||||
quota.setTotalDistributed(new BigDecimal(totalDistributed));
|
||||
quota.setMaxAllowed(maxAllowed);
|
||||
return quota;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user