feat(sprint-3): Phase 2 — club settings controller
This commit is contained in:
@@ -57,6 +57,11 @@
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Web for ResponseStatusException -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
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.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClubService {
|
||||
|
||||
private final ClubRepository clubRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final BatchRepository batchRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Club getClubByTenantId(UUID tenantId) {
|
||||
return clubRepository.findByTenantId(tenantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found for tenant"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Club updateClub(UUID tenantId, String name, String registrationNumber,
|
||||
String contactEmail, String contactPhone,
|
||||
String addressStreet, String addressCity,
|
||||
String addressPostalCode, String addressState,
|
||||
LocalDate foundedDate,
|
||||
Integer maxPreventionOfficers, String allowedEmailPattern) {
|
||||
Club club = getClubByTenantId(tenantId);
|
||||
|
||||
// Validate regex pattern if provided
|
||||
if (allowedEmailPattern != null && !allowedEmailPattern.isBlank()) {
|
||||
validateRegexPattern(allowedEmailPattern);
|
||||
}
|
||||
|
||||
club.setName(name);
|
||||
club.setRegistrationNumber(registrationNumber);
|
||||
club.setContactEmail(contactEmail);
|
||||
club.setContactPhone(contactPhone);
|
||||
club.setAddressStreet(addressStreet);
|
||||
club.setAddressCity(addressCity);
|
||||
club.setAddressPostalCode(addressPostalCode);
|
||||
club.setAddressState(addressState);
|
||||
club.setFoundedDate(foundedDate);
|
||||
if (maxPreventionOfficers != null) {
|
||||
club.setMaxPreventionOfficers(maxPreventionOfficers);
|
||||
}
|
||||
club.setAllowedEmailPattern(allowedEmailPattern);
|
||||
|
||||
return clubRepository.save(club);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ClubStats getClubStats(UUID tenantId) {
|
||||
long totalMembers = memberRepository.countByTenantId(tenantId);
|
||||
long activeMembers = memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE);
|
||||
long totalStaff = staffAccountRepository.countByTenantId(tenantId);
|
||||
long activeStaff = staffAccountRepository.countByTenantIdAndActiveTrue(tenantId);
|
||||
|
||||
// Distributions this month
|
||||
Instant startOfMonth = LocalDate.now().withDayOfMonth(1)
|
||||
.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
long totalDistributionsThisMonth = distributionRepository
|
||||
.countByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
|
||||
BigDecimal totalGramsThisMonth = distributionRepository
|
||||
.sumGramsByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
|
||||
|
||||
long activeBatches = batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE);
|
||||
long preventionOfficerCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId);
|
||||
|
||||
return new ClubStats(
|
||||
totalMembers, activeMembers,
|
||||
totalStaff, activeStaff,
|
||||
totalDistributionsThisMonth,
|
||||
totalGramsThisMonth != null ? totalGramsThisMonth : BigDecimal.ZERO,
|
||||
activeBatches, preventionOfficerCount
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRegexPattern(String pattern) {
|
||||
try {
|
||||
Pattern.compile(pattern);
|
||||
} catch (PatternSyntaxException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Invalid regex pattern for allowedEmailPattern: " + e.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
public record ClubStats(
|
||||
long totalMembers,
|
||||
long activeMembers,
|
||||
long totalStaff,
|
||||
long activeStaff,
|
||||
long totalDistributionsThisMonth,
|
||||
BigDecimal totalGramsDistributedThisMonth,
|
||||
long activeBatches,
|
||||
long preventionOfficerCount
|
||||
) {}
|
||||
}
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -8,4 +9,6 @@ import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
||||
|
||||
long countByTenantIdAndStatus(UUID tenantId, BatchStatus status);
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ClubRepository extends JpaRepository<Club, UUID> {
|
||||
|
||||
Optional<Club> findByTenantId(UUID tenantId);
|
||||
}
|
||||
+9
@@ -20,4 +20,13 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
||||
@Param("dayStart") Instant dayStart,
|
||||
@Param("dayEnd") Instant dayEnd
|
||||
);
|
||||
|
||||
long countByTenantIdAndDistributedAtAfter(UUID tenantId, Instant after);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
|
||||
"WHERE d.tenantId = :tenantId AND d.distributedAt >= :after")
|
||||
BigDecimal sumGramsByTenantIdAndDistributedAtAfter(
|
||||
@Param("tenantId") UUID tenantId,
|
||||
@Param("after") Instant after
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -1,6 +1,7 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -8,4 +9,8 @@ import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
||||
}
|
||||
|
||||
+6
@@ -20,4 +20,10 @@ public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID
|
||||
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
|
||||
|
||||
boolean existsByUserId(UUID userId);
|
||||
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndActiveTrue(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndPreventionOfficerTrue(UUID tenantId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
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.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 ClubServiceTest {
|
||||
|
||||
@Mock
|
||||
private ClubRepository clubRepository;
|
||||
@Mock
|
||||
private MemberRepository memberRepository;
|
||||
@Mock
|
||||
private StaffAccountRepository staffAccountRepository;
|
||||
@Mock
|
||||
private DistributionRepository distributionRepository;
|
||||
@Mock
|
||||
private BatchRepository batchRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ClubService clubService;
|
||||
|
||||
private UUID tenantId;
|
||||
private Club club;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = UUID.randomUUID();
|
||||
club = new Club();
|
||||
club.setId(UUID.randomUUID());
|
||||
club.setTenantId(tenantId);
|
||||
club.setName("Test Club");
|
||||
club.setLicenseNumber("LIC-001");
|
||||
club.setMaxPreventionOfficers(2);
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubByTenantId_found() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
|
||||
Club result = clubService.getClubByTenantId(tenantId);
|
||||
|
||||
assertThat(result).isEqualTo(club);
|
||||
verify(clubRepository).findByTenantId(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubByTenantId_notFound_throws404() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> clubService.getClubByTenantId(tenantId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Club not found for tenant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_success() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Updated Club", "REG-123",
|
||||
"info@club.de", "+49123456",
|
||||
"Mainstreet 1", "Berlin", "10115", "Berlin",
|
||||
LocalDate.of(2024, 1, 15), 3, ".*@club\\.de"
|
||||
);
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Updated Club");
|
||||
assertThat(result.getRegistrationNumber()).isEqualTo("REG-123");
|
||||
assertThat(result.getContactEmail()).isEqualTo("info@club.de");
|
||||
assertThat(result.getMaxPreventionOfficers()).isEqualTo(3);
|
||||
assertThat(result.getAllowedEmailPattern()).isEqualTo(".*@club\\.de");
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_invalidRegex_throwsBadRequest() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
|
||||
assertThatThrownBy(() -> clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, "[invalid"
|
||||
))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Invalid regex pattern");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_nullPattern_accepted() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, null
|
||||
);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_blankPattern_accepted() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, " "
|
||||
);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubStats_returnsAggregatedStats() {
|
||||
when(memberRepository.countByTenantId(tenantId)).thenReturn(50L);
|
||||
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(42L);
|
||||
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(5L);
|
||||
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(4L);
|
||||
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(120L);
|
||||
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(new BigDecimal("1500.50"));
|
||||
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(8L);
|
||||
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(2L);
|
||||
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
|
||||
assertThat(stats.totalMembers()).isEqualTo(50L);
|
||||
assertThat(stats.activeMembers()).isEqualTo(42L);
|
||||
assertThat(stats.totalStaff()).isEqualTo(5L);
|
||||
assertThat(stats.activeStaff()).isEqualTo(4L);
|
||||
assertThat(stats.totalDistributionsThisMonth()).isEqualTo(120L);
|
||||
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo("1500.50");
|
||||
assertThat(stats.activeBatches()).isEqualTo(8L);
|
||||
assertThat(stats.preventionOfficerCount()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubStats_nullGrams_returnsZero() {
|
||||
when(memberRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(0L);
|
||||
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(0L);
|
||||
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(null);
|
||||
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(0L);
|
||||
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
|
||||
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user