feat(sprint-3): Phase 5 — member portal (session-based auth)

This commit is contained in:
Patrick Plate
2026-06-12 10:11:58 +02:00
parent 64927a3244
commit 87568e5bfc
13 changed files with 785 additions and 3 deletions
@@ -0,0 +1,72 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.PortalPrincipal;
import de.cannamanage.service.PortalService;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Member self-service portal — read-only JSON endpoints.
* All data is scoped to the authenticated member via session principal.
*/
@RestController
@RequestMapping("/portal")
public class PortalController {
private final PortalService portalService;
public PortalController(PortalService portalService) {
this.portalService = portalService;
}
/**
* Dashboard: quota summary + recent distributions (last 5).
*/
@GetMapping("/dashboard")
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(dashboard);
}
/**
* Member's own profile.
*/
@GetMapping("/me")
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(profile);
}
/**
* Current month quota status (daily + monthly, used/remaining).
*/
@GetMapping("/quota")
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(quota);
}
/**
* Own distribution history, paginated.
*/
@GetMapping("/distributions")
public ResponseEntity<PortalDistributionHistory> distributions(
@AuthenticationPrincipal PortalPrincipal principal,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
PortalDistributionHistory history = portalService.getDistributionHistory(
principal.getTenantId(), principal.getMemberId(), pageable);
return ResponseEntity.ok(history);
}
}
@@ -0,0 +1,33 @@
package de.cannamanage.api.security;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
import java.util.UUID;
/**
* Custom UserDetails principal for member portal sessions.
* Carries tenantId and memberId so portal controllers can enforce data scoping.
*/
public class PortalPrincipal extends User {
private final UUID tenantId;
private final UUID memberId;
public PortalPrincipal(String username, String password,
Collection<? extends GrantedAuthority> authorities,
UUID tenantId, UUID memberId) {
super(username, password, authorities);
this.tenantId = tenantId;
this.memberId = memberId;
}
public UUID getTenantId() {
return tenantId;
}
public UUID getMemberId() {
return memberId;
}
}
@@ -0,0 +1,55 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* UserDetailsService for portal session-based auth.
* Only loads MEMBER-role users who are active. Members log in by email.
*/
@Service("portalUserDetailsService")
@RequiredArgsConstructor
public class PortalUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
// Only MEMBER role users may use the portal
if (user.getRole() != UserRole.ROLE_MEMBER) {
throw new UsernameNotFoundException("User is not a member");
}
// Must be active
if (!user.isActive()) {
throw new UsernameNotFoundException("User account is inactive");
}
// Must have a linked memberId
if (user.getMemberId() == null) {
throw new UsernameNotFoundException("User has no linked member profile");
}
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
return new PortalPrincipal(
user.getEmail(),
user.getPasswordHash(),
authorities,
user.getTenantId(),
user.getMemberId()
);
}
}
@@ -12,10 +12,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import jakarta.servlet.http.HttpServletResponse;
/** /**
* Security configuration — Sprint 3: API + Staff portal with JWT. * Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service). * Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -24,6 +27,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter; private final JwtAuthFilter jwtAuthFilter;
private final PortalUserDetailsService portalUserDetailsService;
/** /**
* API security — stateless JWT authentication. * API security — stateless JWT authentication.
@@ -53,10 +57,52 @@ public class SecurityConfig {
} }
/** /**
* Public endpoints — Swagger UI, actuator health. * Member portal — session-based authentication with CSRF protection.
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
*/ */
@Bean @Bean
@Order(2) @Order(2)
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/portal/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1))
.userDetailsService(portalUserDetailsService)
.formLogin(form -> form
.loginProcessingUrl("/portal/login")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"ok\"}");
})
.failureHandler((request, response, exception) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
})
.permitAll())
.logout(logout -> logout
.logoutUrl("/portal/logout")
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"logged_out\"}");
}))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
.requestMatchers("/portal/**").hasRole("MEMBER"));
return http.build();
}
/**
* Public endpoints — Swagger UI, actuator health.
*/
@Bean
@Order(3)
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
http http
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health") .securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
@@ -30,3 +30,9 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
# App base URL (for invite links) # App base URL (for invite links)
app.base-url=${APP_BASE_URL:http://localhost:8080} app.base-url=${APP_BASE_URL:http://localhost:8080}
# Session configuration (member portal)
server.servlet.session.timeout=30m
server.servlet.session.cookie.same-site=strict
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
@@ -0,0 +1,112 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.JwtAuthFilter;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.api.security.PortalPrincipal;
import de.cannamanage.api.security.PortalUserDetailsService;
import de.cannamanage.service.PortalService;
import de.cannamanage.service.TokenRevocationService;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import de.cannamanage.service.repository.UserRepository;
import de.cannamanage.domain.enums.MemberStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PortalController.class)
class PortalControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private PortalService portalService;
@MockBean private UserRepository userRepository;
@MockBean private JwtService jwtService;
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean private TokenRevocationService tokenRevocationService;
@MockBean private PortalUserDetailsService portalUserDetailsService;
private final UUID tenantId = UUID.randomUUID();
private final UUID memberId = UUID.randomUUID();
private PortalPrincipal createPrincipal() {
return new PortalPrincipal(
"member@test.de", "password",
List.of(new SimpleGrantedAuthority("ROLE_MEMBER")),
tenantId, memberId
);
}
@Test
void dashboard_returnsCorrectQuotaData() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalDashboard dashboard = new PortalDashboard(
"Max Mustermann", "CM-001",
new BigDecimal("12.5"), new BigDecimal("37.5"),
new BigDecimal("5.0"), new BigDecimal("20.0"),
List.of()
);
when(portalService.getDashboard(tenantId, memberId)).thenReturn(dashboard);
mockMvc.perform(get("/portal/dashboard").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberName").value("Max Mustermann"))
.andExpect(jsonPath("$.membershipNumber").value("CM-001"))
.andExpect(jsonPath("$.monthlyQuotaUsed").value(12.5))
.andExpect(jsonPath("$.dailyQuotaRemaining").value(20.0));
}
@Test
void portal_returns403_forUnauthenticated() throws Exception {
mockMvc.perform(get("/portal/dashboard"))
.andExpect(status().isUnauthorized());
}
@Test
void profile_returnsOwnData() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalProfile profile = new PortalProfile(
"Max", "Mustermann", "CM-001",
LocalDate.of(2025, 1, 15), MemberStatus.ACTIVE, "max@example.com"
);
when(portalService.getProfile(tenantId, memberId)).thenReturn(profile);
mockMvc.perform(get("/portal/me").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Max"))
.andExpect(jsonPath("$.membershipNumber").value("CM-001"));
}
@Test
void quota_returnsCurrentMonthStatus() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalQuota quota = new PortalQuota(
2026, 6,
new BigDecimal("5.0"), new BigDecimal("25.0"),
new BigDecimal("20.0"), new BigDecimal("50.0"),
false, new BigDecimal("30.0")
);
when(portalService.getQuota(tenantId, memberId)).thenReturn(quota);
mockMvc.perform(get("/portal/quota").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.year").value(2026))
.andExpect(jsonPath("$.month").value(6))
.andExpect(jsonPath("$.dailyUsed").value(5.0))
.andExpect(jsonPath("$.monthlyLimit").value(50.0));
}
}
@@ -0,0 +1,174 @@
package de.cannamanage.service;
import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.entity.User;
import de.cannamanage.service.dto.QuotaStatus;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StrainRepository;
import de.cannamanage.service.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.List;
import java.util.UUID;
/**
* Service layer for the member self-service portal.
* All methods enforce member-scoped data access — only the specified memberId's data is returned.
*/
@Service
@Transactional(readOnly = true)
public class PortalService {
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
private final ComplianceService complianceService;
private final BatchRepository batchRepository;
private final StrainRepository strainRepository;
private final UserRepository userRepository;
public PortalService(MemberRepository memberRepository,
DistributionRepository distributionRepository,
ComplianceService complianceService,
BatchRepository batchRepository,
StrainRepository strainRepository,
UserRepository userRepository) {
this.memberRepository = memberRepository;
this.distributionRepository = distributionRepository;
this.complianceService = complianceService;
this.batchRepository = batchRepository;
this.strainRepository = strainRepository;
this.userRepository = userRepository;
}
/**
* Dashboard: quota summary + last 5 distributions.
*/
public PortalDashboard getDashboard(UUID tenantId, UUID memberId) {
Member member = loadMember(memberId);
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
// Daily usage
LocalDate today = LocalDate.now(ZoneOffset.UTC);
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
// Recent 5 distributions
List<Distribution> recent = distributionRepository
.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId);
List<PortalDashboard.RecentDistribution> recentDtos = recent.stream()
.map(d -> new PortalDashboard.RecentDistribution(
d.getDistributedAt(),
resolveStrainName(d.getBatchId()),
d.getQuantityGrams(),
resolveStaffName(d.getRecordedBy())
))
.toList();
return new PortalDashboard(
member.getFirstName() + " " + member.getLastName(),
member.getMembershipNumber(),
quota.totalUsed(),
quota.remaining(),
dailyUsed,
dailyLimit.subtract(dailyUsed),
recentDtos
);
}
/**
* Member's own profile.
*/
public PortalProfile getProfile(UUID tenantId, UUID memberId) {
Member member = loadMember(memberId);
return new PortalProfile(
member.getFirstName(),
member.getLastName(),
member.getMembershipNumber(),
member.getMembershipDate(),
member.getStatus(),
member.getEmail()
);
}
/**
* Detailed quota status for current month.
*/
public PortalQuota getQuota(UUID tenantId, UUID memberId) {
Member member = loadMember(memberId);
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
// Daily usage
LocalDate today = LocalDate.now(ZoneOffset.UTC);
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
return new PortalQuota(
quota.year(),
quota.month(),
dailyUsed,
ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS,
quota.totalUsed(),
quota.totalAllowed(),
member.isUnder21(),
ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
);
}
/**
* Paginated distribution history for the member.
*/
public PortalDistributionHistory getDistributionHistory(UUID tenantId, UUID memberId, Pageable pageable) {
Page<Distribution> page = distributionRepository
.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable);
List<PortalDistributionHistory.DistributionEntry> entries = page.getContent().stream()
.map(d -> new PortalDistributionHistory.DistributionEntry(
d.getDistributedAt(),
resolveStrainName(d.getBatchId()),
d.getQuantityGrams(),
resolveStaffName(d.getRecordedBy())
))
.toList();
return new PortalDistributionHistory(entries, page.getNumber(), page.getTotalPages(), page.getTotalElements());
}
private Member loadMember(UUID memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(memberId));
}
private String resolveStrainName(UUID batchId) {
return batchRepository.findById(batchId)
.flatMap(batch -> strainRepository.findById(batch.getStrainId()))
.map(Strain::getName)
.orElse("Unknown");
}
private String resolveStaffName(UUID userId) {
return userRepository.findById(userId)
.map(User::getEmail)
.orElse("Unknown");
}
}
@@ -0,0 +1,25 @@
package de.cannamanage.service.dto.portal;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* Dashboard overview for portal members — quota summary + recent distributions.
*/
public record PortalDashboard(
String memberName,
String membershipNumber,
BigDecimal monthlyQuotaUsed,
BigDecimal monthlyQuotaRemaining,
BigDecimal dailyQuotaUsed,
BigDecimal dailyQuotaRemaining,
List<RecentDistribution> recentDistributions
) {
public record RecentDistribution(
Instant date,
String strainName,
BigDecimal grams,
String staffName
) {}
}
@@ -0,0 +1,22 @@
package de.cannamanage.service.dto.portal;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* Paginated distribution history for a member.
*/
public record PortalDistributionHistory(
List<DistributionEntry> distributions,
int page,
int totalPages,
long totalElements
) {
public record DistributionEntry(
Instant date,
String strainName,
BigDecimal grams,
String staffName
) {}
}
@@ -0,0 +1,17 @@
package de.cannamanage.service.dto.portal;
import de.cannamanage.domain.enums.MemberStatus;
import java.time.LocalDate;
/**
* Member's own profile information for the portal.
*/
public record PortalProfile(
String firstName,
String lastName,
String membershipNumber,
LocalDate membershipDate,
MemberStatus status,
String email
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.service.dto.portal;
import java.math.BigDecimal;
/**
* Detailed quota status for the current month.
*/
public record PortalQuota(
int year,
int month,
BigDecimal dailyUsed,
BigDecimal dailyLimit,
BigDecimal monthlyUsed,
BigDecimal monthlyLimit,
boolean isUnder21,
BigDecimal under21MonthlyLimit
) {}
@@ -1,6 +1,8 @@
package de.cannamanage.service.repository; package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Distribution; import de.cannamanage.domain.entity.Distribution;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -52,4 +54,14 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " + @Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
"ORDER BY d.distributedAt DESC LIMIT 1") "ORDER BY d.distributedAt DESC LIMIT 1")
Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId); Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId);
/**
* Find the 5 most recent distributions for a specific member (portal dashboard).
*/
List<Distribution> findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId);
/**
* Paginated distribution history for a member, newest first (portal history).
*/
Page<Distribution> findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable);
} }
@@ -0,0 +1,191 @@
package de.cannamanage.service;
import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.dto.QuotaStatus;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StrainRepository;
import de.cannamanage.service.repository.UserRepository;
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.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PortalServiceTest {
@Mock private MemberRepository memberRepository;
@Mock private DistributionRepository distributionRepository;
@Mock private ComplianceService complianceService;
@Mock private BatchRepository batchRepository;
@Mock private StrainRepository strainRepository;
@Mock private UserRepository userRepository;
@InjectMocks private PortalService portalService;
private final UUID tenantId = UUID.randomUUID();
private final UUID memberId = UUID.randomUUID();
private Member testMember;
@BeforeEach
void setUp() {
testMember = new Member();
testMember.setFirstName("Max");
testMember.setLastName("Mustermann");
testMember.setMembershipNumber("CM-001");
testMember.setMembershipDate(LocalDate.of(2025, 1, 15));
testMember.setStatus(MemberStatus.ACTIVE);
testMember.setEmail("max@example.com");
testMember.setUnder21(false);
}
@Test
void getDashboard_returnsCorrectQuotaData() {
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
when(complianceService.getQuotaStatus(memberId)).thenReturn(
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("12.5"),
new BigDecimal("37.5"), false, 2026, 6));
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
.thenReturn(new BigDecimal("5.0"));
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId))
.thenReturn(List.of());
PortalDashboard dashboard = portalService.getDashboard(tenantId, memberId);
assertThat(dashboard.memberName()).isEqualTo("Max Mustermann");
assertThat(dashboard.membershipNumber()).isEqualTo("CM-001");
assertThat(dashboard.monthlyQuotaUsed()).isEqualByComparingTo("12.5");
assertThat(dashboard.monthlyQuotaRemaining()).isEqualByComparingTo("37.5");
assertThat(dashboard.dailyQuotaUsed()).isEqualByComparingTo("5.0");
assertThat(dashboard.dailyQuotaRemaining()).isEqualByComparingTo("20.0");
assertThat(dashboard.recentDistributions()).isEmpty();
}
@Test
void getProfile_returnsOwnMemberData() {
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
PortalProfile profile = portalService.getProfile(tenantId, memberId);
assertThat(profile.firstName()).isEqualTo("Max");
assertThat(profile.lastName()).isEqualTo("Mustermann");
assertThat(profile.membershipNumber()).isEqualTo("CM-001");
assertThat(profile.membershipDate()).isEqualTo(LocalDate.of(2025, 1, 15));
assertThat(profile.status()).isEqualTo(MemberStatus.ACTIVE);
assertThat(profile.email()).isEqualTo("max@example.com");
}
@Test
void getQuota_returnsDetailedQuotaStatus() {
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
when(complianceService.getQuotaStatus(memberId)).thenReturn(
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("20.0"),
new BigDecimal("30.0"), false, 2026, 6));
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
.thenReturn(new BigDecimal("10.0"));
PortalQuota quota = portalService.getQuota(tenantId, memberId);
assertThat(quota.year()).isEqualTo(2026);
assertThat(quota.month()).isEqualTo(6);
assertThat(quota.dailyUsed()).isEqualByComparingTo("10.0");
assertThat(quota.dailyLimit()).isEqualByComparingTo(ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS);
assertThat(quota.monthlyUsed()).isEqualByComparingTo("20.0");
assertThat(quota.monthlyLimit()).isEqualByComparingTo("50.0");
assertThat(quota.isUnder21()).isFalse();
}
@Test
void getDistributionHistory_returnsPaginatedResults() {
UUID batchId = UUID.randomUUID();
UUID staffId = UUID.randomUUID();
UUID strainId = UUID.randomUUID();
Distribution dist = new Distribution();
dist.setDistributedAt(Instant.now());
dist.setBatchId(batchId);
dist.setQuantityGrams(new BigDecimal("3.5"));
dist.setRecordedBy(staffId);
Batch batch = new Batch();
batch.setStrainId(strainId);
Strain strain = new Strain();
strain.setName("Blue Dream");
User staff = new User();
staff.setEmail("staff@club.de");
Pageable pageable = PageRequest.of(0, 20);
when(distributionRepository.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable))
.thenReturn(new PageImpl<>(List.of(dist), pageable, 1));
when(batchRepository.findById(batchId)).thenReturn(Optional.of(batch));
when(strainRepository.findById(strainId)).thenReturn(Optional.of(strain));
when(userRepository.findById(staffId)).thenReturn(Optional.of(staff));
PortalDistributionHistory history = portalService.getDistributionHistory(tenantId, memberId, pageable);
assertThat(history.totalElements()).isEqualTo(1);
assertThat(history.page()).isEqualTo(0);
assertThat(history.distributions()).hasSize(1);
assertThat(history.distributions().getFirst().strainName()).isEqualTo("Blue Dream");
assertThat(history.distributions().getFirst().grams()).isEqualByComparingTo("3.5");
assertThat(history.distributions().getFirst().staffName()).isEqualTo("staff@club.de");
}
@Test
void getDashboard_memberOnlySeesOwnData() {
// A different memberId — service uses the passed memberId, not some other lookup
UUID otherMemberId = UUID.randomUUID();
Member otherMember = new Member();
otherMember.setFirstName("Anna");
otherMember.setLastName("Schmidt");
otherMember.setMembershipNumber("CM-002");
otherMember.setMembershipDate(LocalDate.of(2025, 3, 1));
otherMember.setStatus(MemberStatus.ACTIVE);
otherMember.setEmail("anna@example.com");
otherMember.setUnder21(true);
when(memberRepository.findById(otherMemberId)).thenReturn(Optional.of(otherMember));
when(complianceService.getQuotaStatus(otherMemberId)).thenReturn(
new QuotaStatus(new BigDecimal("30.0"), new BigDecimal("5.0"),
new BigDecimal("25.0"), true, 2026, 6));
when(distributionRepository.sumQuantityByMemberAndDay(eq(otherMemberId), any(), any()))
.thenReturn(BigDecimal.ZERO);
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(otherMemberId, tenantId))
.thenReturn(List.of());
PortalDashboard dashboard = portalService.getDashboard(tenantId, otherMemberId);
// Verifies it returns ANNA's data, not MAX's — proving member-scoped isolation
assertThat(dashboard.memberName()).isEqualTo("Anna Schmidt");
assertThat(dashboard.membershipNumber()).isEqualTo("CM-002");
}
}