diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java index c91eaf7..1d4e813 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java @@ -3,9 +3,12 @@ package de.cannamanage.api.controller; import de.cannamanage.api.dto.member.CreateMemberRequest; import de.cannamanage.api.dto.member.MemberResponse; import de.cannamanage.api.dto.member.UpdateMemberRequest; +import de.cannamanage.api.dto.prevention.PreventionDataResponse; +import de.cannamanage.api.dto.prevention.Under21MemberResponse; import de.cannamanage.domain.entity.Member; import de.cannamanage.domain.entity.TenantContext; import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.PreventionOfficerService; import de.cannamanage.service.repository.MemberRepository; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -17,6 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.Period; import java.util.List; @@ -29,6 +33,7 @@ import java.util.UUID; public class MemberController { private final MemberRepository memberRepository; + private final PreventionOfficerService preventionOfficerService; @GetMapping @Operation(summary = "List all members", description = "Returns all members for the current tenant") @@ -89,6 +94,57 @@ public class MemberController { return ResponseEntity.ok(toResponse(saved)); } + @GetMapping("/under-21") + @Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.") + @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)") + public ResponseEntity> getUnder21Members() { + UUID tenantId = TenantContext.getCurrentTenant(); + List under21Members = preventionOfficerService.getUnder21Members(tenantId); + + List response = under21Members.stream() + .map(m -> { + int age = preventionOfficerService.calculateAge(m.getDateOfBirth()); + long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId()); + BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId()); + BigDecimal limit = preventionOfficerService.getMonthlyLimit(m); + BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO); + String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED"; + return new Under21MemberResponse( + m.getId(), m.getFirstName(), m.getLastName(), + age, m.getDateOfBirth(), distCount, + gramsUsed, limit, quotaStatus + ); + }) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/prevention-data") + @Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.") + @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)") + public ResponseEntity getPreventionData(@PathVariable UUID id) { + UUID tenantId = TenantContext.getCurrentTenant(); + Member member = memberRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + int age = preventionOfficerService.calculateAge(member.getDateOfBirth()); + long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId()); + BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId()); + BigDecimal limit = preventionOfficerService.getMonthlyLimit(member); + BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO); + + return ResponseEntity.ok(new PreventionDataResponse( + member.getId(), + member.getFirstName() + " " + member.getLastName(), + member.isUnder21(), + age, + distCount, + gramsUsed, + limit, + remaining + )); + } + private boolean isUnder21(LocalDate dateOfBirth) { return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21; } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java index 4763ea8..39bcfd0 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java @@ -1,5 +1,6 @@ package de.cannamanage.api.controller; +import de.cannamanage.api.dto.prevention.PreventionOfficerRequest; import de.cannamanage.api.dto.staff.CreateStaffRequest; import de.cannamanage.api.dto.staff.StaffResponse; import de.cannamanage.api.dto.staff.UpdateStaffRequest; @@ -7,6 +8,7 @@ import de.cannamanage.domain.entity.StaffAccount; import de.cannamanage.domain.entity.TenantContext; import de.cannamanage.domain.entity.User; import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.PreventionOfficerService; import de.cannamanage.service.StaffService; import de.cannamanage.service.StaffTemplates; import de.cannamanage.service.repository.UserRepository; @@ -31,6 +33,7 @@ import java.util.UUID; public class StaffController { private final StaffService staffService; + private final PreventionOfficerService preventionOfficerService; private final UserRepository userRepository; @GetMapping @@ -103,6 +106,19 @@ public class StaffController { return ResponseEntity.noContent().build(); } + @PutMapping("/{id}/prevention-officer") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Assign or revoke prevention officer status", + description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.") + public ResponseEntity setPreventionOfficer(@PathVariable UUID id, + @Valid @RequestBody PreventionOfficerRequest request) { + UUID tenantId = TenantContext.getCurrentTenant(); + StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer()); + User user = userRepository.findById(staff.getUserId()).orElse(null); + String email = user != null ? user.getEmail() : "unknown"; + return ResponseEntity.ok(StaffResponse.from(staff, email)); + } + @GetMapping("/templates") @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "List available permission templates") diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionDataResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionDataResponse.java new file mode 100644 index 0000000..e7d8057 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionDataResponse.java @@ -0,0 +1,15 @@ +package de.cannamanage.api.dto.prevention; + +import java.math.BigDecimal; +import java.util.UUID; + +public record PreventionDataResponse( + UUID memberId, + String name, + boolean isUnder21, + int age, + long currentMonthDistributions, + BigDecimal gramsUsedThisMonth, + BigDecimal monthlyLimit, + BigDecimal quotaRemaining +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionOfficerRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionOfficerRequest.java new file mode 100644 index 0000000..56a3447 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/PreventionOfficerRequest.java @@ -0,0 +1,7 @@ +package de.cannamanage.api.dto.prevention; + +import jakarta.validation.constraints.NotNull; + +public record PreventionOfficerRequest( + @NotNull Boolean preventionOfficer +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/Under21MemberResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/Under21MemberResponse.java new file mode 100644 index 0000000..1a35d37 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/prevention/Under21MemberResponse.java @@ -0,0 +1,17 @@ +package de.cannamanage.api.dto.prevention; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record Under21MemberResponse( + UUID id, + String firstName, + String lastName, + int age, + LocalDate dateOfBirth, + long totalDistributionsThisMonth, + BigDecimal gramsUsedThisMonth, + BigDecimal monthlyLimit, + String quotaStatus +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java index 1345970..3c1de82 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java @@ -19,6 +19,7 @@ public record StaffResponse( Set permissions, String templateName, boolean active, + boolean preventionOfficer, Instant createdAt ) { public static StaffResponse from(StaffAccount staff, User user) { @@ -30,6 +31,7 @@ public record StaffResponse( staff.getGrantedPermissions(), null, // templateName not stored; permissions are expanded staff.isActive(), + staff.isPreventionOfficer(), staff.getCreatedAt() ); } @@ -43,6 +45,7 @@ public record StaffResponse( staff.getGrantedPermissions(), null, staff.isActive(), + staff.isPreventionOfficer(), staff.getCreatedAt() ); } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java index 3cabf06..ecddc8f 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package de.cannamanage.api.exception; import de.cannamanage.api.service.AuthService; import de.cannamanage.service.exception.BatchNotFoundException; import de.cannamanage.service.exception.MemberNotFoundException; +import de.cannamanage.service.exception.PreventionOfficerLimitExceededException; import de.cannamanage.service.exception.QuotaExceededException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -108,6 +109,18 @@ public class GlobalExceptionHandler { return problem; } + @ExceptionHandler(PreventionOfficerLimitExceededException.class) + public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problem.setTitle("Prevention Officer Limit Exceeded"); + problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED")); + problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED"); + problem.setProperty("maxAllowed", ex.getMaxAllowed()); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + @ExceptionHandler(ResponseStatusException.class) public ProblemDetail handleResponseStatus(ResponseStatusException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java index 491d6c3..5481217 100644 --- a/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java @@ -10,6 +10,7 @@ import de.cannamanage.domain.entity.TenantContext; import de.cannamanage.domain.entity.User; import de.cannamanage.domain.enums.StaffPermission; import de.cannamanage.domain.enums.UserRole; +import de.cannamanage.service.PreventionOfficerService; import de.cannamanage.service.StaffService; import de.cannamanage.service.TokenRevocationService; import de.cannamanage.service.repository.UserRepository; @@ -40,6 +41,7 @@ class StaffControllerTest { @Autowired private ObjectMapper objectMapper; @MockBean private StaffService staffService; + @MockBean private PreventionOfficerService preventionOfficerService; @MockBean private UserRepository userRepository; @MockBean private JwtService jwtService; @MockBean private JwtAuthFilter jwtAuthFilter; diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PreventionOfficerService.java b/cannamanage-service/src/main/java/de/cannamanage/service/PreventionOfficerService.java new file mode 100644 index 0000000..b1bb890 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PreventionOfficerService.java @@ -0,0 +1,136 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.constants.ComplianceConstants; +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.Distribution; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.service.exception.PreventionOfficerLimitExceededException; +import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.DistributionRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.StaffAccountRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.Period; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Service for prevention officer assignment and under-21 member monitoring. + * Enforces the configurable limit per club and provides prevention-relevant data. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PreventionOfficerService { + + private final StaffAccountRepository staffAccountRepository; + private final ClubRepository clubRepository; + private final MemberRepository memberRepository; + private final DistributionRepository distributionRepository; + + /** + * Assign or revoke prevention officer status on a staff account. + * On assign: enforces club.maxPreventionOfficers limit. + * On revoke: always succeeds. + */ + @Transactional + public StaffAccount setPreventionOfficer(UUID tenantId, UUID staffId, boolean assign) { + StaffAccount staff = staffAccountRepository.findById(staffId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found")); + + if (!staff.getTenantId().equals(tenantId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found"); + } + + if (assign) { + // Check limit before assigning + long currentCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId); + Club club = clubRepository.findById(tenantId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found")); + + if (currentCount >= club.getMaxPreventionOfficers()) { + throw new PreventionOfficerLimitExceededException(club.getMaxPreventionOfficers()); + } + + staff.setPreventionOfficer(true); + log.info("Prevention officer assigned: staffId={}, tenantId={}", staffId, tenantId); + } else { + staff.setPreventionOfficer(false); + log.info("Prevention officer revoked: staffId={}, tenantId={}", staffId, tenantId); + } + + return staffAccountRepository.save(staff); + } + + /** + * Returns all under-21 members for the tenant with their current month distribution data. + */ + @Transactional(readOnly = true) + public List getUnder21Members(UUID tenantId) { + return memberRepository.findByTenantIdAndUnder21True(tenantId); + } + + /** + * Returns the number of distributions for a member in the current month. + */ + @Transactional(readOnly = true) + public long countCurrentMonthDistributions(UUID tenantId, UUID memberId) { + Instant monthStart = getMonthStart(); + Instant now = Instant.now(); + List distributions = distributionRepository + .findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now); + return distributions.stream() + .filter(d -> d.getMemberId().equals(memberId)) + .count(); + } + + /** + * Returns the total grams distributed to a member in the current month. + */ + @Transactional(readOnly = true) + public BigDecimal sumCurrentMonthGrams(UUID tenantId, UUID memberId) { + Instant monthStart = getMonthStart(); + Instant now = Instant.now(); + List distributions = distributionRepository + .findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now); + return distributions.stream() + .filter(d -> d.getMemberId().equals(memberId)) + .map(Distribution::getQuantityGrams) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * Calculates the monthly limit for a member based on their age. + */ + public BigDecimal getMonthlyLimit(Member member) { + if (member.isUnder21()) { + return ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS; + } + return ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS; + } + + /** + * Calculates the age of a member. + */ + public int calculateAge(LocalDate dateOfBirth) { + return Period.between(dateOfBirth, LocalDate.now()).getYears(); + } + + private Instant getMonthStart() { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Europe/Berlin")); + ZonedDateTime monthStart = now.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + return monthStart.toInstant(); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/PreventionOfficerLimitExceededException.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/PreventionOfficerLimitExceededException.java new file mode 100644 index 0000000..2993d1e --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/PreventionOfficerLimitExceededException.java @@ -0,0 +1,19 @@ +package de.cannamanage.service.exception; + +/** + * Thrown when attempting to assign more prevention officers than the club limit allows. + * Maps to HTTP 409 Conflict. + */ +public class PreventionOfficerLimitExceededException extends RuntimeException { + + private final int maxAllowed; + + public PreventionOfficerLimitExceededException(int maxAllowed) { + super("Prevention officer limit exceeded. Maximum allowed: " + maxAllowed); + this.maxAllowed = maxAllowed; + } + + public int getMaxAllowed() { + return maxAllowed; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java index adc98cb..27879ee 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -24,4 +24,9 @@ public interface MemberRepository extends JpaRepository { * Find all members for a tenant (all statuses). */ List findByTenantId(UUID tenantId); + + /** + * Find all under-21 members for a tenant. + */ + List findByTenantIdAndUnder21True(UUID tenantId); } diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/PreventionOfficerServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/PreventionOfficerServiceTest.java new file mode 100644 index 0000000..c387c65 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/PreventionOfficerServiceTest.java @@ -0,0 +1,233 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.Distribution; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.service.exception.PreventionOfficerLimitExceededException; +import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.DistributionRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.StaffAccountRepository; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +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.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PreventionOfficerServiceTest { + + @Mock private StaffAccountRepository staffAccountRepository; + @Mock private ClubRepository clubRepository; + @Mock private MemberRepository memberRepository; + @Mock private DistributionRepository distributionRepository; + + @InjectMocks private PreventionOfficerService service; + + private UUID tenantId; + private UUID staffId; + private StaffAccount staffAccount; + private Club club; + + @BeforeEach + void setUp() { + tenantId = UUID.randomUUID(); + staffId = UUID.randomUUID(); + + staffAccount = new StaffAccount(); + staffAccount.setId(staffId); + staffAccount.setTenantId(tenantId); + staffAccount.setUserId(UUID.randomUUID()); + staffAccount.setDisplayName("Test Staff"); + staffAccount.setActive(true); + staffAccount.setPreventionOfficer(false); + + club = new Club(); + club.setId(tenantId); + club.setTenantId(tenantId); + club.setMaxPreventionOfficers(2); + } + + // --- Assignment Tests --- + + @Test + void assignPreventionOfficer_underLimit_succeeds() { + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount)); + when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(1L); + when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club)); + when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + StaffAccount result = service.setPreventionOfficer(tenantId, staffId, true); + + assertThat(result.isPreventionOfficer()).isTrue(); + verify(staffAccountRepository).save(staffAccount); + } + + @Test + void assignPreventionOfficer_atLimit_throws409() { + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount)); + when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(2L); + when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club)); + + assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true)) + .isInstanceOf(PreventionOfficerLimitExceededException.class) + .hasMessageContaining("Maximum allowed: 2"); + + verify(staffAccountRepository, never()).save(any()); + } + + @Test + void revokePreventionOfficer_alwaysSucceeds() { + staffAccount.setPreventionOfficer(true); + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount)); + when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + StaffAccount result = service.setPreventionOfficer(tenantId, staffId, false); + + assertThat(result.isPreventionOfficer()).isFalse(); + verify(staffAccountRepository).save(staffAccount); + } + + @Test + void assignPreventionOfficer_wrongTenant_throws404() { + UUID otherTenant = UUID.randomUUID(); + staffAccount.setTenantId(otherTenant); + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount)); + + assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not found"); + } + + @Test + void assignPreventionOfficer_notFound_throws404() { + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not found"); + } + + // --- Under-21 Members --- + + @Test + void getUnder21Members_returnsList() { + Member member = new Member(); + member.setId(UUID.randomUUID()); + member.setTenantId(tenantId); + member.setUnder21(true); + member.setFirstName("Young"); + member.setLastName("Member"); + member.setDateOfBirth(LocalDate.now().minusYears(20)); + + when(memberRepository.findByTenantIdAndUnder21True(tenantId)).thenReturn(List.of(member)); + + List result = service.getUnder21Members(tenantId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).isUnder21()).isTrue(); + } + + // --- Distribution Counting --- + + @Test + void countCurrentMonthDistributions_countsCorrectly() { + UUID memberId = UUID.randomUUID(); + Distribution d1 = new Distribution(); + d1.setMemberId(memberId); + d1.setDistributedAt(Instant.now()); + d1.setQuantityGrams(BigDecimal.valueOf(5)); + + Distribution d2 = new Distribution(); + d2.setMemberId(memberId); + d2.setDistributedAt(Instant.now()); + d2.setQuantityGrams(BigDecimal.valueOf(10)); + + Distribution otherMemberDist = new Distribution(); + otherMemberDist.setMemberId(UUID.randomUUID()); + otherMemberDist.setDistributedAt(Instant.now()); + otherMemberDist.setQuantityGrams(BigDecimal.valueOf(7)); + + when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any())) + .thenReturn(List.of(d1, d2, otherMemberDist)); + + long count = service.countCurrentMonthDistributions(tenantId, memberId); + + assertThat(count).isEqualTo(2); + } + + @Test + void sumCurrentMonthGrams_sumsCorrectly() { + UUID memberId = UUID.randomUUID(); + Distribution d1 = new Distribution(); + d1.setMemberId(memberId); + d1.setQuantityGrams(BigDecimal.valueOf(5)); + d1.setDistributedAt(Instant.now()); + + Distribution d2 = new Distribution(); + d2.setMemberId(memberId); + d2.setQuantityGrams(BigDecimal.valueOf(10)); + d2.setDistributedAt(Instant.now()); + + when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any())) + .thenReturn(List.of(d1, d2)); + + BigDecimal grams = service.sumCurrentMonthGrams(tenantId, memberId); + + assertThat(grams).isEqualByComparingTo(BigDecimal.valueOf(15)); + } + + // --- Monthly Limit --- + + @Test + void getMonthlyLimit_under21_returns30() { + Member member = new Member(); + member.setUnder21(true); + + BigDecimal limit = service.getMonthlyLimit(member); + + assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(30)); + } + + @Test + void getMonthlyLimit_adult_returns50() { + Member member = new Member(); + member.setUnder21(false); + + BigDecimal limit = service.getMonthlyLimit(member); + + assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(50)); + } + + // --- Age Calculation --- + + @Test + void calculateAge_returnsCorrectAge() { + LocalDate dob = LocalDate.now().minusYears(20).minusDays(10); + int age = service.calculateAge(dob); + assertThat(age).isEqualTo(20); + } + + @Test + void calculateAge_birthday_today() { + LocalDate dob = LocalDate.now().minusYears(21); + int age = service.calculateAge(dob); + assertThat(age).isEqualTo(21); + } +}