feat(sprint-3): Phase 2 — club settings controller

This commit is contained in:
Patrick Plate
2026-06-11 16:56:44 +02:00
parent 55d8434f35
commit 36deb72cf0
15 changed files with 700 additions and 0 deletions
@@ -0,0 +1,94 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.club.ClubResponse;
import de.cannamanage.api.dto.club.ClubStatsResponse;
import de.cannamanage.api.dto.club.UpdateClubRequest;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.ClubService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/clubs")
@RequiredArgsConstructor
@Tag(name = "Club Settings", description = "Club configuration and statistics")
public class ClubController {
private final ClubService clubService;
@GetMapping("/me")
@Operation(summary = "Get current club", description = "Returns the club for the current tenant")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ClubResponse> getMyClub() {
UUID tenantId = TenantContext.getCurrentTenant();
Club club = clubService.getClubByTenantId(tenantId);
return ResponseEntity.ok(toResponse(club));
}
@PutMapping("/me")
@Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ClubResponse> updateMyClub(@Valid @RequestBody UpdateClubRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
Club updated = clubService.updateClub(
tenantId,
request.name(),
request.registrationNumber(),
request.contactEmail(),
request.contactPhone(),
request.addressStreet(),
request.addressCity(),
request.addressPostalCode(),
request.addressState(),
request.foundedDate(),
request.maxPreventionOfficers(),
request.allowedEmailPattern()
);
return ResponseEntity.ok(toResponse(updated));
}
@GetMapping("/me/stats")
@Operation(summary = "Get club statistics", description = "Returns aggregated club statistics")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<ClubStatsResponse> getMyClubStats() {
UUID tenantId = TenantContext.getCurrentTenant();
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
return ResponseEntity.ok(new ClubStatsResponse(
stats.totalMembers(),
stats.activeMembers(),
stats.totalStaff(),
stats.activeStaff(),
stats.totalDistributionsThisMonth(),
stats.totalGramsDistributedThisMonth(),
stats.activeBatches(),
stats.preventionOfficerCount()
));
}
private ClubResponse toResponse(Club club) {
return new ClubResponse(
club.getId(),
club.getName(),
club.getRegistrationNumber(),
club.getContactEmail(),
club.getContactPhone(),
club.getAddressStreet(),
club.getAddressCity(),
club.getAddressPostalCode(),
club.getAddressState(),
club.getFoundedDate(),
club.getMaxPreventionOfficers(),
club.getAllowedEmailPattern(),
club.getStatus(),
club.getCreatedAt()
);
}
}
@@ -0,0 +1,24 @@
package de.cannamanage.api.dto.club;
import de.cannamanage.domain.enums.ClubStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
public record ClubResponse(
UUID id,
String name,
String registrationNumber,
String contactEmail,
String contactPhone,
String addressStreet,
String addressCity,
String addressPostalCode,
String addressState,
LocalDate foundedDate,
Integer maxPreventionOfficers,
String allowedEmailPattern,
ClubStatus status,
Instant createdAt
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.club;
import java.math.BigDecimal;
public record ClubStatsResponse(
long totalMembers,
long activeMembers,
long totalStaff,
long activeStaff,
long totalDistributionsThisMonth,
BigDecimal totalGramsDistributedThisMonth,
long activeBatches,
long preventionOfficerCount
) {}
@@ -0,0 +1,34 @@
package de.cannamanage.api.dto.club;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDate;
public record UpdateClubRequest(
@NotBlank(message = "Club name is required")
String name,
String registrationNumber,
@Email(message = "Must be a valid email address")
String contactEmail,
String contactPhone,
String addressStreet,
String addressCity,
String addressPostalCode,
String addressState,
LocalDate foundedDate,
@Min(value = 1, message = "Must have at least 1 prevention officer slot")
Integer maxPreventionOfficers,
String allowedEmailPattern
) {}
@@ -0,0 +1,12 @@
-- Sprint 3 Phase 2: Club settings extended columns
-- Additional address fields, contact info, and allowed email pattern for clubs
ALTER TABLE clubs ADD COLUMN registration_number VARCHAR(100);
ALTER TABLE clubs ADD COLUMN contact_email VARCHAR(255);
ALTER TABLE clubs ADD COLUMN contact_phone VARCHAR(50);
ALTER TABLE clubs ADD COLUMN address_street VARCHAR(255);
ALTER TABLE clubs ADD COLUMN address_city VARCHAR(100);
ALTER TABLE clubs ADD COLUMN address_postal_code VARCHAR(20);
ALTER TABLE clubs ADD COLUMN address_state VARCHAR(100);
ALTER TABLE clubs ADD COLUMN founded_date DATE;
ALTER TABLE clubs ADD COLUMN allowed_email_pattern VARCHAR(255);
@@ -0,0 +1,113 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.club.UpdateClubRequest;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ClubStatus;
import de.cannamanage.service.ClubService;
import org.junit.jupiter.api.AfterEach;
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.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ClubControllerTest {
@Mock
private ClubService clubService;
@InjectMocks
private ClubController clubController;
private UUID tenantId;
private Club club;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
TenantContext.setCurrentTenant(tenantId);
club = new Club();
club.setId(UUID.randomUUID());
club.setTenantId(tenantId);
club.setName("Green Garden Club");
club.setRegistrationNumber("REG-2024-001");
club.setContactEmail("info@greengardenclub.de");
club.setContactPhone("+49 30 12345678");
club.setAddressStreet("Hanfweg 42");
club.setAddressCity("Berlin");
club.setAddressPostalCode("10115");
club.setAddressState("Berlin");
club.setFoundedDate(LocalDate.of(2024, 7, 1));
club.setMaxPreventionOfficers(2);
club.setAllowedEmailPattern(".*@greengardenclub\\.de");
club.setStatus(ClubStatus.ACTIVE);
club.setCreatedAt(Instant.now());
club.setLicenseNumber("LIC-001");
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void getMyClub_returnsClubResponse() {
when(clubService.getClubByTenantId(tenantId)).thenReturn(club);
ResponseEntity<?> response = clubController.getMyClub();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(clubService).getClubByTenantId(tenantId);
}
@Test
void updateMyClub_updatesAndReturns() {
UpdateClubRequest request = new UpdateClubRequest(
"Updated Club", "REG-NEW", "new@club.de", "+49111",
"Newstreet 1", "Hamburg", "20095", "Hamburg",
LocalDate.of(2024, 1, 1), 3, ".*@club\\.de"
);
when(clubService.updateClub(
eq(tenantId), eq("Updated Club"), eq("REG-NEW"),
eq("new@club.de"), eq("+49111"),
eq("Newstreet 1"), eq("Hamburg"), eq("20095"), eq("Hamburg"),
eq(LocalDate.of(2024, 1, 1)), eq(3), eq(".*@club\\.de")
)).thenReturn(club);
ResponseEntity<?> response = clubController.updateMyClub(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
void getMyClubStats_returnsStats() {
ClubService.ClubStats stats = new ClubService.ClubStats(
50, 42, 5, 4, 120, new BigDecimal("1500.50"), 8, 2
);
when(clubService.getClubStats(tenantId)).thenReturn(stats);
ResponseEntity<?> response = clubController.getMyClubStats();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(clubService).getClubStats(tenantId);
}
}
@@ -3,6 +3,8 @@ package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ClubStatus; import de.cannamanage.domain.enums.ClubStatus;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDate;
@Entity @Entity
@Table(name = "clubs") @Table(name = "clubs")
public class Club extends AbstractTenantEntity { public class Club extends AbstractTenantEntity {
@@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255) @Column(name = "name", nullable = false, length = 255)
private String name; private String name;
@Column(name = "registration_number", length = 100)
private String registrationNumber;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "contact_phone", length = 50)
private String contactPhone;
@Column(name = "address_street", length = 255)
private String addressStreet;
@Column(name = "address_city", length = 100)
private String addressCity;
@Column(name = "address_postal_code", length = 20)
private String addressPostalCode;
@Column(name = "address_state", length = 100)
private String addressState;
@Column(name = "founded_date")
private LocalDate foundedDate;
@Column(name = "address") @Column(name = "address")
private String address; private String address;
@@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity {
@Column(name = "max_members", nullable = false) @Column(name = "max_members", nullable = false)
private Integer maxMembers = 500; private Integer maxMembers = 500;
@Column(name = "max_prevention_officers", nullable = false)
private Integer maxPreventionOfficers = 2;
@Column(name = "allowed_email_pattern", length = 255)
private String allowedEmailPattern;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50) @Column(name = "status", nullable = false, length = 50)
private ClubStatus status = ClubStatus.ACTIVE; private ClubStatus status = ClubStatus.ACTIVE;
@@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity {
public String getName() { return name; } public String getName() { return name; }
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
public String getRegistrationNumber() { return registrationNumber; }
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
public String getContactEmail() { return contactEmail; }
public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getAddressStreet() { return addressStreet; }
public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; }
public String getAddressCity() { return addressCity; }
public void setAddressCity(String addressCity) { this.addressCity = addressCity; }
public String getAddressPostalCode() { return addressPostalCode; }
public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; }
public String getAddressState() { return addressState; }
public void setAddressState(String addressState) { this.addressState = addressState; }
public LocalDate getFoundedDate() { return foundedDate; }
public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; }
public String getAddress() { return address; } public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; } public void setAddress(String address) { this.address = address; }
@@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity {
public Integer getMaxMembers() { return maxMembers; } public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; } public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; }
public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; }
public String getAllowedEmailPattern() { return allowedEmailPattern; }
public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; }
public ClubStatus getStatus() { return status; } public ClubStatus getStatus() { return status; }
public void setStatus(ClubStatus status) { this.status = status; } public void setStatus(ClubStatus status) { this.status = status; }
} }
+5
View File
@@ -57,6 +57,11 @@
<groupId>org.springframework</groupId> <groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId> <artifactId>spring-context</artifactId>
</dependency> </dependency>
<!-- Spring Web for ResponseStatusException -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <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
) {}
}
@@ -1,6 +1,7 @@
package de.cannamanage.service.repository; package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Batch; import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.enums.BatchStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -8,4 +9,6 @@ import java.util.UUID;
@Repository @Repository
public interface BatchRepository extends JpaRepository<Batch, UUID> { public interface BatchRepository extends JpaRepository<Batch, UUID> {
long countByTenantIdAndStatus(UUID tenantId, BatchStatus status);
} }
@@ -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);
}
@@ -20,4 +20,13 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
@Param("dayStart") Instant dayStart, @Param("dayStart") Instant dayStart,
@Param("dayEnd") Instant dayEnd @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
);
} }
@@ -1,6 +1,7 @@
package de.cannamanage.service.repository; package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Member; import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.MemberStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -8,4 +9,8 @@ import java.util.UUID;
@Repository @Repository
public interface MemberRepository extends JpaRepository<Member, UUID> { public interface MemberRepository extends JpaRepository<Member, UUID> {
long countByTenantId(UUID tenantId);
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
} }
@@ -20,4 +20,10 @@ public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId); long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
boolean existsByUserId(UUID userId); 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);
}
}