feat(sprint-3): Phase 6 — prevention officer capability
- PreventionOfficerService: limit enforcement, under-21 monitoring, monthly distribution tracking
- PUT /api/v1/staff/{id}/prevention-officer: assign/revoke with club limit check (409 on exceed)
- GET /api/v1/members/under-21: list under-21 members with quota data (prevention officer access)
- GET /api/v1/members/{id}/prevention-data: member prevention details (quota, distributions)
- PreventionOfficerLimitExceededException mapped to 409 in GlobalExceptionHandler
- StaffResponse extended with preventionOfficer field
- PreventionOfficerServiceTest: 10 unit tests covering assignment, revocation, limits, age calc
- MemberRepository.findByTenantIdAndUnder21True added
This commit is contained in:
+233
@@ -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<Member> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user