diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/config/SecurityConfig.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/config/SecurityConfig.java index c80d600..e9fc5f8 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/config/SecurityConfig.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/config/SecurityConfig.java @@ -17,7 +17,7 @@ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(proxyTargetClass = true) public class SecurityConfig { @Bean diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/FinanceController.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/FinanceController.java new file mode 100644 index 0000000..edb56ba --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/FinanceController.java @@ -0,0 +1,95 @@ +package tum.devoops.financeservice.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.RestController; +import tum.devoops.financeservice.api.FinanceApi; +import tum.devoops.financeservice.model.Balance; +import tum.devoops.financeservice.model.Transaction; +import tum.devoops.financeservice.model.TransactionCreate; +import tum.devoops.financeservice.model.TransactionPartialUpdate; +import tum.devoops.financeservice.service.TransactionService; + +import java.util.List; +import java.util.UUID; + +@RestController +@PreAuthorize("hasAnyRole('admin', 'member')") +public class FinanceController implements FinanceApi { + + @Autowired + TransactionService transactionService; + + @Override + public ResponseEntity createTransaction(TransactionCreate transactionCreate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + Transaction created = transactionService.createTransaction(transactionCreate, requesterId, isAdmin); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @Override + public ResponseEntity deleteTransaction(UUID transactionId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + transactionService.deleteTransaction(transactionId, requesterId, isAdmin); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity> getAllTransactions() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(transactionService.getAllTransactions(requesterId, isAdmin)); + } + + @Override + public ResponseEntity getTransaction(UUID transactionId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(transactionService.getTransaction(transactionId, requesterId, isAdmin)); + } + + @Override + public ResponseEntity updateTransaction(UUID transactionId, TransactionPartialUpdate transactionPartialUpdate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(transactionService.updateTransaction(transactionId, transactionPartialUpdate, requesterId, isAdmin)); + } + + @Override + public ResponseEntity> getAllBalances() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(transactionService.getAllBalances(requesterId, isAdmin)); + } + + @Override + public ResponseEntity getMemberBalance(UUID memberId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(transactionService.getMemberBalance(memberId, requesterId, isAdmin)); + } + + private UUID extractRequesterId(Authentication auth) { + Jwt jwt = (Jwt) auth.getPrincipal(); + return UUID.fromString(jwt.getSubject()); + } + + private boolean extractIsAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/HelloController.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java similarity index 85% rename from services/spring-finance/src/main/java/tum/devoops/financeservice/HelloController.java rename to services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java index 6244652..7db57a9 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/HelloController.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java @@ -1,4 +1,4 @@ -package tum.devoops.financeservice; +package tum.devoops.financeservice.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/converter/TransactionConverter.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/converter/TransactionConverter.java new file mode 100644 index 0000000..3c02e0e --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/converter/TransactionConverter.java @@ -0,0 +1,35 @@ +package tum.devoops.financeservice.converter; + +import tum.devoops.financeservice.entity.TransactionEntity; +import tum.devoops.financeservice.model.Transaction; +import tum.devoops.financeservice.model.TransactionCreate; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; + +public class TransactionConverter { + + public static Transaction toTransaction(TransactionEntity entity) { + return new Transaction( + entity.getId(), + entity.getMemberId().toString(), + entity.getCreatorId().toString(), + entity.getAmountCents(), + entity.getCreatedAt().atOffset(ZoneOffset.UTC), + entity.getTitle(), + entity.getDescription() + ); + } + + public static TransactionEntity toEntity(TransactionCreate create, UUID memberId, UUID creatorId) { + TransactionEntity entity = new TransactionEntity(); + entity.setMemberId(memberId); + entity.setCreatorId(creatorId); + entity.setAmountCents(create.getAmountCents()); + entity.setCreatedAt(Instant.now()); + entity.setTitle(create.getTitle()); + entity.setDescription(create.getDescription() != null ? create.getDescription() : ""); + return entity; + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java new file mode 100644 index 0000000..ff667f9 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java @@ -0,0 +1,32 @@ +package tum.devoops.financeservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import lombok.Getter; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.UUID; + +@Entity +@Table(schema = "organization", name = "directors") +@Getter @NoArgsConstructor +public class DirectorEntity { + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "sport_name", nullable = false) + private String sportName; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/MemberEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/MemberEntity.java new file mode 100644 index 0000000..f8061a8 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/MemberEntity.java @@ -0,0 +1,20 @@ +package tum.devoops.financeservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(schema = "member", name="members") +@Getter +@NoArgsConstructor +public class MemberEntity { + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java new file mode 100644 index 0000000..ed97c54 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java @@ -0,0 +1,35 @@ +package tum.devoops.financeservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.OneToMany; +import jakarta.persistence.JoinColumn; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Entity +@Table(schema = "organization", name = "teams") +@Getter +@NoArgsConstructor +public class TeamEntity { + + @Id + @Column(name = "id", nullable = false) + UUID id; + + @Column(name = "sport_name", nullable = false) + private String sportName; + + @OneToMany + @JoinColumn(name = "team_id", referencedColumnName = "id", insertable = false, updatable = false) + private List trainers; + + @OneToMany + @JoinColumn(name = "team_id", referencedColumnName = "id", insertable = false, updatable = false) + private List trainees; +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TraineeEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TraineeEntity.java new file mode 100644 index 0000000..83e66a6 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TraineeEntity.java @@ -0,0 +1,32 @@ +package tum.devoops.financeservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Data; +import java.io.Serializable; +import java.util.UUID; + +@Entity +@Table(schema = "organization", name = "trainees") +@Getter +@NoArgsConstructor @AllArgsConstructor +public class TraineeEntity { + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TrainerEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TrainerEntity.java new file mode 100644 index 0000000..230000e --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TrainerEntity.java @@ -0,0 +1,31 @@ +package tum.devoops.financeservice.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Data; +import java.io.Serializable; +import java.util.UUID; + +@Entity +@Table(schema = "organization", name = "trainers") +@Getter +public class TrainerEntity { + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java index bda6351..cca8dad 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java @@ -23,12 +23,12 @@ public class TransactionEntity { @Column(name = "id", nullable = false, updatable = false) private UUID id; - // FK to member.member(id) added in V3 migration. + // FK to member.members(id), added in the V2 migration. @Column(name = "member_id", nullable = false) private UUID memberId; // UUID of the member who created this transaction. - // FK to member.member(id) added in V3 migration. + // FK to member.members(id), added in the V2 migration. @Column(name = "creator_id", nullable = false) private UUID creatorId; diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/BadRequestException.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/BadRequestException.java new file mode 100644 index 0000000..73f9e49 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package tum.devoops.financeservice.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/ForbiddenException.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/ForbiddenException.java new file mode 100644 index 0000000..2b22245 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package tum.devoops.financeservice.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/GlobalExceptionHandler.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9555f35 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,31 @@ +package tum.devoops.financeservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import tum.devoops.financeservice.model.BadRequestResponse; +import tum.devoops.financeservice.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequest(BadRequestException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new BadRequestResponse().message(ex.getMessage())); + } + +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/NotFoundException.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/NotFoundException.java new file mode 100644 index 0000000..251c3c1 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package tum.devoops.financeservice.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java new file mode 100644 index 0000000..2656935 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.financeservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import tum.devoops.financeservice.entity.DirectorEntity; + +import java.util.List; +import java.util.UUID; + +public interface DirectorRepository extends JpaRepository { + @Query("SELECT d.id.sportName FROM DirectorEntity d WHERE d.id.memberId = :memberId") + List findSportNamesByMemberId(@Param("memberId") UUID memberId); +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/MemberRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/MemberRepository.java new file mode 100644 index 0000000..6cbaaac --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/MemberRepository.java @@ -0,0 +1,9 @@ +package tum.devoops.financeservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import tum.devoops.financeservice.entity.MemberEntity; + +import java.util.UUID; + +public interface MemberRepository extends JpaRepository { +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java new file mode 100644 index 0000000..8b8a172 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java @@ -0,0 +1,18 @@ +package tum.devoops.financeservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import tum.devoops.financeservice.entity.TeamEntity; +import tum.devoops.financeservice.entity.TraineeEntity; + +import java.util.List; +import java.util.UUID; + +public interface TeamRepository extends JpaRepository { + @Query("SELECT t.trainees FROM TeamEntity t WHERE t.sportName = :sportName") + List findTraineesBySportName(@Param("sportName") String sportName); + + @Query("SELECT t.trainees FROM TeamEntity t WHERE t.id = :teamId") + List findTraineesByTeamId(@Param("teamId") UUID teamId); +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TraineeRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TraineeRepository.java new file mode 100644 index 0000000..d1c286e --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TraineeRepository.java @@ -0,0 +1,7 @@ +package tum.devoops.financeservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import tum.devoops.financeservice.entity.TraineeEntity; + +public interface TraineeRepository extends JpaRepository { +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TrainerRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TrainerRepository.java new file mode 100644 index 0000000..91de497 --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TrainerRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.financeservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import tum.devoops.financeservice.entity.TrainerEntity; + +import java.util.List; +import java.util.UUID; + +public interface TrainerRepository extends JpaRepository { + @Query("SELECT t.id.teamId FROM TrainerEntity t WHERE t.id.memberId = :memberId") + List findTeamIdByMemberId(@Param("memberId") UUID memberId); +} diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java new file mode 100644 index 0000000..34e4acf --- /dev/null +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java @@ -0,0 +1,238 @@ +package tum.devoops.financeservice.service; + +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import tum.devoops.financeservice.converter.TransactionConverter; +import tum.devoops.financeservice.entity.TeamEntity; +import tum.devoops.financeservice.entity.TraineeEntity; +import tum.devoops.financeservice.entity.TransactionEntity; +import tum.devoops.financeservice.exception.BadRequestException; +import tum.devoops.financeservice.exception.ForbiddenException; +import tum.devoops.financeservice.exception.NotFoundException; +import tum.devoops.financeservice.model.Balance; +import tum.devoops.financeservice.model.Transaction; +import tum.devoops.financeservice.model.TransactionCreate; +import tum.devoops.financeservice.model.TransactionPartialUpdate; +import tum.devoops.financeservice.repository.DirectorRepository; +import tum.devoops.financeservice.repository.MemberRepository; +import tum.devoops.financeservice.repository.TeamRepository; +import tum.devoops.financeservice.repository.TrainerRepository; +import tum.devoops.financeservice.repository.TransactionRepository; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class TransactionService { + + @Autowired TransactionRepository transactionRepository; + @Autowired MemberRepository memberRepository; + @Autowired DirectorRepository directorRepository; + @Autowired TeamRepository teamRepository; + @Autowired TrainerRepository trainerRepository; + + @Transactional + public Transaction createTransaction(TransactionCreate transactionCreate, UUID requesterId, boolean isAdmin) { + UUID memberId; + try { + memberId = UUID.fromString(transactionCreate.getMember()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid memberId format."); + } + + if (memberRepository.findById(memberId).isEmpty()) { + throw new NotFoundException("Member not found."); + } + + if (!isAdmin && !isDirectorOfMember(requesterId, memberId) && !isTrainerOfMember(requesterId, memberId)) { + throw new ForbiddenException("Only admins, directors, or trainers of a member can create transactions for them."); + } + + TransactionEntity saved = transactionRepository.save(TransactionConverter.toEntity(transactionCreate, memberId, requesterId)); + return TransactionConverter.toTransaction(saved); + } + + public List getAllTransactions(UUID requesterId, boolean isAdmin) { + if (isAdmin) { + return transactionRepository.findAll().stream() + .map(TransactionConverter::toTransaction) + .toList(); + } + + // Deduplicate by ID in case multiple queries return the same row. + LinkedHashMap seen = new LinkedHashMap<>(); + transactionRepository.findAllByMemberId(requesterId).forEach(e -> seen.put(e.getId(), e)); + transactionRepository.findAllByCreatorId(requesterId).forEach(e -> seen.put(e.getId(), e)); + for (UUID managedId : getManagedMemberIds(requesterId)) { + transactionRepository.findAllByMemberId(managedId).forEach(e -> seen.put(e.getId(), e)); + } + return seen.values().stream().map(TransactionConverter::toTransaction).toList(); + } + + public Transaction getTransaction(UUID transactionId, UUID requesterId, boolean isAdmin) { + TransactionEntity entity = transactionRepository.findById(transactionId) + .orElseThrow(() -> new NotFoundException("Transaction not found.")); + + UUID memberId = entity.getMemberId(); + boolean canAccess = isAdmin + || requesterId.equals(memberId) + || requesterId.equals(entity.getCreatorId()) + || isDirectorOfMember(requesterId, memberId) + || isTrainerOfMember(requesterId, memberId); + + if (!canAccess) { + throw new ForbiddenException("Access denied."); + } + + return TransactionConverter.toTransaction(entity); + } + + @Transactional + public void deleteTransaction(UUID transactionId, UUID requesterId, boolean isAdmin) { + TransactionEntity entity = transactionRepository.findById(transactionId) + .orElseThrow(() -> new NotFoundException("Transaction not found.")); + + if (!isAdmin && !requesterId.equals(entity.getCreatorId())) { + throw new ForbiddenException("Only the creator or an admin can delete this transaction."); + } + + transactionRepository.delete(entity); + } + + @Transactional + public Transaction updateTransaction(UUID transactionId, TransactionPartialUpdate update, UUID requesterId, boolean isAdmin) { + TransactionEntity entity = transactionRepository.findById(transactionId) + .orElseThrow(() -> new NotFoundException("Transaction not found.")); + + if (!isAdmin && !requesterId.equals(entity.getCreatorId())) { + throw new ForbiddenException("Only the creator or an admin can update this transaction."); + } + + if (update.getMember() != null) { + if (!isAdmin) { + throw new ForbiddenException("Only admins can change the member field."); + } + UUID newMemberId; + try { + newMemberId = UUID.fromString(update.getMember()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid memberId format."); + } + if (memberRepository.findById(newMemberId).isEmpty()) { + throw new NotFoundException("Member not found."); + } + entity.setMemberId(newMemberId); + } + + if (update.getAmountCents() != null) { + entity.setAmountCents(update.getAmountCents()); + } + + if (update.getTitle() != null) { + entity.setTitle(update.getTitle()); + } + + if (update.getDescription() != null) { + entity.setDescription(update.getDescription()); + } + + return TransactionConverter.toTransaction(transactionRepository.save(entity)); + } + + public List getAllBalances(UUID requesterId, boolean isAdmin) { + List transactions; + if (isAdmin) { + transactions = transactionRepository.findAll(); + } else { + List managedIds = getManagedMemberIds(requesterId); + if (managedIds.isEmpty()) { + throw new ForbiddenException("Only admins, directors, or trainers can view all balances."); + } + LinkedHashMap seen = new LinkedHashMap<>(); + for (UUID memberId : managedIds) { + transactionRepository.findAllByMemberId(memberId).forEach(e -> seen.put(e.getId(), e)); + } + transactions = List.copyOf(seen.values()); + } + + return transactions.stream() + .collect(Collectors.groupingBy( + TransactionEntity::getMemberId, + Collectors.summingInt(TransactionEntity::getAmountCents))) + .entrySet().stream() + .map(e -> new Balance(e.getKey().toString(), e.getValue())) + .toList(); + } + + public Balance getMemberBalance(UUID memberId, UUID requesterId, boolean isAdmin) { + if (memberRepository.findById(memberId).isEmpty()) { + throw new NotFoundException("Member not found."); + } + + boolean canAccess = isAdmin + || requesterId.equals(memberId) + || isDirectorOfMember(requesterId, memberId) + || isTrainerOfMember(requesterId, memberId); + + if (!canAccess) { + throw new ForbiddenException("Access denied."); + } + + int balance = transactionRepository.findAllByMemberId(memberId).stream() + .mapToInt(TransactionEntity::getAmountCents) + .sum(); + + return new Balance(memberId.toString(), balance); + } + + // Returns distinct IDs of all members the requester can manage (as director or trainer). + private List getManagedMemberIds(UUID requesterId) { + Set ids = new LinkedHashSet<>(); + for (String sport : directorRepository.findSportNamesByMemberId(requesterId)) { + teamRepository.findTraineesBySportName(sport).stream() + .map(t -> t.getId().getMemberId()) + .forEach(ids::add); + } + for (UUID teamId : trainerRepository.findTeamIdByMemberId(requesterId)) { + teamRepository.findById(teamId).ifPresent(team -> + teamRepository.findTraineesByTeamId(team.getId()).stream() + .map(t -> t.getId().getMemberId()) + .forEach(ids::add)); + } + return List.copyOf(ids); + } + + private boolean isDirectorOfMember(UUID requesterId, UUID memberId) { + for (String sport : directorRepository.findSportNamesByMemberId(requesterId)) { + boolean found = teamRepository.findTraineesBySportName(sport).stream() + .map(t -> t.getId().getMemberId()) + .anyMatch(memberId::equals); + if (found) { + return true; + } + } + return false; + } + + private boolean isTrainerOfMember(UUID requesterId, UUID memberId) { + for (UUID teamId : trainerRepository.findTeamIdByMemberId(requesterId)) { + Optional team = teamRepository.findById(teamId); + if (team.isPresent()) { + boolean found = teamRepository.findTraineesByTeamId(team.get().getId()).stream() + .map(TraineeEntity::getId) + .map(TraineeEntity.Id::getMemberId) + .anyMatch(memberId::equals); + if (found) { + return true; + } + } + } + return false; + } +} diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceControllerTest.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceControllerTest.java new file mode 100644 index 0000000..b6ae22f --- /dev/null +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceControllerTest.java @@ -0,0 +1,295 @@ +package tum.devoops.financeservice; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor; +import org.springframework.test.web.servlet.MockMvc; +import tum.devoops.financeservice.config.SecurityConfig; +import tum.devoops.financeservice.controller.FinanceController; +import tum.devoops.financeservice.model.Balance; +import tum.devoops.financeservice.model.Transaction; +import tum.devoops.financeservice.model.TransactionCreate; +import tum.devoops.financeservice.model.TransactionPartialUpdate; +import tum.devoops.financeservice.service.TransactionService; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(FinanceController.class) +@Import(SecurityConfig.class) +class FinanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private TransactionService transactionService; + + private static final UUID REQUESTER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID TX_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + + private JwtRequestPostProcessor memberJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")); + } + + private JwtRequestPostProcessor adminJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")); + } + + private Transaction sampleTransaction() { + return new Transaction(TX_ID, MEMBER_ID.toString(), REQUESTER_ID.toString(), + 500, OffsetDateTime.now(), "Membership fee", null); + } + + // ── POST /finance/transactions ───────────────────────────────────────────── + + @Test + void createTransactionUnauthenticatedReturns401() throws Exception { + mockMvc.perform(post("/finance/transactions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TransactionCreate(MEMBER_ID.toString(), 500, "Membership fee")))) + .andExpect(status().isUnauthorized()); + } + + @Test + void createTransactionNoMatchingRoleReturns403() throws Exception { + mockMvc.perform(post("/finance/transactions") + .with(jwt().jwt(j -> j.subject(REQUESTER_ID.toString()))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TransactionCreate(MEMBER_ID.toString(), 500, "Membership fee")))) + .andExpect(status().isForbidden()); + } + + @Test + void createTransactionAsMemberReturns201() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.createTransaction(any(), eq(REQUESTER_ID), eq(false))).thenReturn(tx); + + mockMvc.perform(post("/finance/transactions") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TransactionCreate(MEMBER_ID.toString(), 500, "Membership fee")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(TX_ID.toString())); + } + + @Test + void createTransactionAsAdminReturns201() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.createTransaction(any(), eq(REQUESTER_ID), eq(true))).thenReturn(tx); + + mockMvc.perform(post("/finance/transactions") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TransactionCreate(MEMBER_ID.toString(), 500, "Membership fee")))) + .andExpect(status().isCreated()); + } + + @Test + void createTransactionMissingBodyReturns400() throws Exception { + mockMvc.perform(post("/finance/transactions") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + // ── GET /finance/transactions ────────────────────────────────────────────── + + @Test + void getAllTransactionsUnauthenticatedReturns401() throws Exception { + mockMvc.perform(get("/finance/transactions")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getAllTransactionsAsMemberReturns200() throws Exception { + when(transactionService.getAllTransactions(REQUESTER_ID, false)) + .thenReturn(List.of(sampleTransaction())); + + mockMvc.perform(get("/finance/transactions").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].id").value(TX_ID.toString())); + } + + @Test + void getAllTransactionsAsAdminReturns200() throws Exception { + when(transactionService.getAllTransactions(REQUESTER_ID, true)).thenReturn(List.of()); + + mockMvc.perform(get("/finance/transactions").with(adminJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // ── GET /finance/transactions/{id} ──────────────────────────────────────── + + @Test + void getTransactionUnauthenticatedReturns401() throws Exception { + mockMvc.perform(get("/finance/transactions/{id}", TX_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getTransactionAsMemberReturns200() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.getTransaction(TX_ID, REQUESTER_ID, false)).thenReturn(tx); + + mockMvc.perform(get("/finance/transactions/{id}", TX_ID).with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(TX_ID.toString())); + } + + @Test + void getTransactionAsAdminReturns200() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.getTransaction(TX_ID, REQUESTER_ID, true)).thenReturn(tx); + + mockMvc.perform(get("/finance/transactions/{id}", TX_ID).with(adminJwt())) + .andExpect(status().isOk()); + } + + // ── DELETE /finance/transactions/{id} ───────────────────────────────────── + + @Test + void deleteTransactionUnauthenticatedReturns401() throws Exception { + mockMvc.perform(delete("/finance/transactions/{id}", TX_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void deleteTransactionAsMemberReturns204() throws Exception { + doNothing().when(transactionService).deleteTransaction(TX_ID, REQUESTER_ID, false); + + mockMvc.perform(delete("/finance/transactions/{id}", TX_ID).with(memberJwt())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteTransactionAsAdminReturns204() throws Exception { + doNothing().when(transactionService).deleteTransaction(TX_ID, REQUESTER_ID, true); + + mockMvc.perform(delete("/finance/transactions/{id}", TX_ID).with(adminJwt())) + .andExpect(status().isNoContent()); + } + + // ── PATCH /finance/transactions/{id} ────────────────────────────────────── + + @Test + void updateTransactionUnauthenticatedReturns401() throws Exception { + mockMvc.perform(patch("/finance/transactions/{id}", TX_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void updateTransactionAsMemberReturns200() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.updateTransaction(eq(TX_ID), any(), eq(REQUESTER_ID), eq(false))) + .thenReturn(tx); + + mockMvc.perform(patch("/finance/transactions/{id}", TX_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TransactionPartialUpdate().title("Updated")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(TX_ID.toString())); + } + + @Test + void updateTransactionAsAdminReturns200() throws Exception { + Transaction tx = sampleTransaction(); + when(transactionService.updateTransaction(eq(TX_ID), any(), eq(REQUESTER_ID), eq(true))) + .thenReturn(tx); + + mockMvc.perform(patch("/finance/transactions/{id}", TX_ID) + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TransactionPartialUpdate().amountCents(999)))) + .andExpect(status().isOk()); + } + + // ── GET /finance/balances ───────────────────────────────────────────────── + + @Test + void getAllBalancesUnauthenticatedReturns401() throws Exception { + mockMvc.perform(get("/finance/balances")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getAllBalancesAsMemberReturns200() throws Exception { + when(transactionService.getAllBalances(REQUESTER_ID, false)) + .thenReturn(List.of(new Balance(MEMBER_ID.toString(), 200))); + + mockMvc.perform(get("/finance/balances").with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].balance_cents").value(200)); + } + + @Test + void getAllBalancesAsAdminReturns200() throws Exception { + when(transactionService.getAllBalances(REQUESTER_ID, true)).thenReturn(List.of()); + + mockMvc.perform(get("/finance/balances").with(adminJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + // ── GET /finance/balances/{member_id} ──────────────────────────────────── + + @Test + void getMemberBalanceUnauthenticatedReturns401() throws Exception { + mockMvc.perform(get("/finance/balances/{memberId}", MEMBER_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getMemberBalanceAsMemberReturns200() throws Exception { + Balance balance = new Balance(MEMBER_ID.toString(), 500); + when(transactionService.getMemberBalance(MEMBER_ID, REQUESTER_ID, false)).thenReturn(balance); + + mockMvc.perform(get("/finance/balances/{memberId}", MEMBER_ID).with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.balance_cents").value(500)); + } + + @Test + void getMemberBalanceAsAdminReturns200() throws Exception { + Balance balance = new Balance(MEMBER_ID.toString(), 300); + when(transactionService.getMemberBalance(MEMBER_ID, REQUESTER_ID, true)).thenReturn(balance); + + mockMvc.perform(get("/finance/balances/{memberId}", MEMBER_ID).with(adminJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.balance_cents").value(300)); + } +} diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java index 82f1d92..b6d5343 100644 --- a/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java @@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import tum.devoops.financeservice.config.SecurityConfig; +import tum.devoops.financeservice.controller.HelloController; @WebMvcTest(HelloController.class) @Import(SecurityConfig.class) diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceServiceApplicationTests.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceApplicationTests.java similarity index 52% rename from services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceServiceApplicationTests.java rename to services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceApplicationTests.java index 8de688c..22376fb 100644 --- a/services/spring-finance/src/test/java/tum/devoops/financeservice/FinanceServiceApplicationTests.java +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceApplicationTests.java @@ -2,7 +2,13 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import tum.devoops.financeservice.repository.DirectorRepository; +import tum.devoops.financeservice.repository.MemberRepository; +import tum.devoops.financeservice.repository.TeamRepository; +import tum.devoops.financeservice.repository.TrainerRepository; +import tum.devoops.financeservice.repository.TransactionRepository; /** * Context-load smoke test. @@ -18,7 +24,13 @@ @TestPropertySource(properties = { "spring.jpa.hibernate.ddl-auto=none" }) -class FinanceServiceApplicationTests { +class TransactionServiceApplicationTests { + + @MockBean TransactionRepository transactionRepository; + @MockBean MemberRepository memberRepository; + @MockBean DirectorRepository directorRepository; + @MockBean TeamRepository teamRepository; + @MockBean TrainerRepository trainerRepository; @Test void contextLoads() { diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java new file mode 100644 index 0000000..7805826 --- /dev/null +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java @@ -0,0 +1,554 @@ +package tum.devoops.financeservice; + +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 tum.devoops.financeservice.entity.MemberEntity; +import tum.devoops.financeservice.entity.TeamEntity; +import tum.devoops.financeservice.entity.TraineeEntity; +import tum.devoops.financeservice.entity.TransactionEntity; +import tum.devoops.financeservice.exception.BadRequestException; +import tum.devoops.financeservice.exception.ForbiddenException; +import tum.devoops.financeservice.exception.NotFoundException; +import tum.devoops.financeservice.model.Balance; +import tum.devoops.financeservice.model.Transaction; +import tum.devoops.financeservice.model.TransactionCreate; +import tum.devoops.financeservice.model.TransactionPartialUpdate; +import tum.devoops.financeservice.repository.DirectorRepository; +import tum.devoops.financeservice.repository.MemberRepository; +import tum.devoops.financeservice.repository.TeamRepository; +import tum.devoops.financeservice.repository.TrainerRepository; +import tum.devoops.financeservice.repository.TransactionRepository; +import tum.devoops.financeservice.service.TransactionService; + +import java.time.Instant; +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.tuple; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TransactionServiceTest { + + @Mock TransactionRepository transactionRepository; + @Mock MemberRepository memberRepository; + @Mock DirectorRepository directorRepository; + @Mock TeamRepository teamRepository; + @Mock TrainerRepository trainerRepository; + + @InjectMocks + TransactionService service; + + private static final UUID REQUESTER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID TEAM_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); + private static final UUID TX_ID = UUID.fromString("00000000-0000-0000-0000-000000000099"); + + // ── createTransaction: input validation ─────────────────────────────────── + + @Test + void createTransactionInvalidMemberUuidThrowsBadRequest() { + assertThrows(BadRequestException.class, + () -> service.createTransaction(new TransactionCreate("not-a-uuid", 500, "Test"), REQUESTER_ID, false)); + } + + @Test + void createTransactionMemberNotFoundThrowsNotFoundException() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, + () -> service.createTransaction(validRequest(), REQUESTER_ID, false)); + } + + // ── createTransaction: authorization ───────────────────────────────────── + + @Test + void createTransactionNeitherDirectorNorTrainerNorAdminThrowsForbidden() { + memberExists(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + assertThrows(ForbiddenException.class, + () -> service.createTransaction(validRequest(), REQUESTER_ID, false)); + } + + @Test + void createTransactionAdminPassesAuthWithoutDirectorOrTrainerRole() { + // isAdmin=true short-circuits the director/trainer checks entirely. + memberExists(); + when(transactionRepository.save(any())).thenReturn(savedEntity()); + + Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, true); + + assertThat(result.getId()).isEqualTo(TX_ID); + assertThat(result.getMember()).isEqualTo(MEMBER_ID.toString()); + assertThat(result.getCreator()).isEqualTo(REQUESTER_ID.toString()); + } + + @Test + void createTransactionAsDirectorOfMembersTeamReturnsCreatedTransaction() { + memberExists(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); + when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(transactionRepository.save(any())).thenReturn(savedEntity()); + + Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(TX_ID); + assertThat(result.getAmountCents()).isEqualTo(500); + } + + @Test + void createTransactionAsTrainerOfMembersTeamReturnsCreatedTransaction() { + memberExists(); + TeamEntity team = mockTeam(TEAM_ID); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(teamRepository.findTraineesByTeamId(TEAM_ID)).thenReturn(List.of(trainee(MEMBER_ID))); + when(transactionRepository.save(any())).thenReturn(savedEntity()); + + Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(TX_ID); + } + + // ── isDirectorOfMember edge cases ───────────────────────────────────────── + + @Test + void createTransactionDirectorOfDifferentSportThrowsForbidden() { + memberExists(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("football")); + when(teamRepository.findTraineesBySportName("football")).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + assertThrows(ForbiddenException.class, + () -> service.createTransaction(validRequest(), REQUESTER_ID, false)); + } + + @Test + void createTransactionDirectorOfMultipleSportsFindsMemberInSecondSport() { + memberExists(); + UUID otherMemberId = UUID.randomUUID(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("football", "tennis")); + when(teamRepository.findTraineesBySportName("football")).thenReturn(List.of(trainee(otherMemberId))); + when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + // Short-circuits at director — trainer check never reached. + when(transactionRepository.save(any())).thenReturn(savedEntity()); + + Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(TX_ID); + } + + // ── isTrainerOfMember edge cases ────────────────────────────────────────── + + @Test + void createTransactionTrainerButMemberOnDifferentTeamThrowsForbidden() { + memberExists(); + UUID otherMemberId = UUID.randomUUID(); + TeamEntity team = mockTeam(TEAM_ID); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); + when(teamRepository.findTraineesByTeamId(TEAM_ID)).thenReturn(List.of(trainee(otherMemberId))); + + assertThrows(ForbiddenException.class, + () -> service.createTransaction(validRequest(), REQUESTER_ID, false)); + } + + @Test + void createTransactionTrainerTeamNotInDatabaseThrowsForbidden() { + memberExists(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + + assertThrows(ForbiddenException.class, + () -> service.createTransaction(validRequest(), REQUESTER_ID, false)); + } + + @Test + void createTransactionTrainerOnMultipleTeamsFindsMemberInSecondTeam() { + memberExists(); + UUID otherTeamId = UUID.fromString("00000000-0000-0000-0000-000000000004"); + UUID otherMemberId = UUID.randomUUID(); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + TeamEntity team1 = mockTeam(TEAM_ID); + TeamEntity team2 = mockTeam(otherTeamId); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID, otherTeamId)); + when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team1)); + when(teamRepository.findById(otherTeamId)).thenReturn(Optional.of(team2)); + when(teamRepository.findTraineesByTeamId(TEAM_ID)).thenReturn(List.of(trainee(otherMemberId))); + when(teamRepository.findTraineesByTeamId(otherTeamId)).thenReturn(List.of(trainee(MEMBER_ID))); + when(transactionRepository.save(any())).thenReturn(savedEntity()); + + Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(TX_ID); + } + + // ── getAllTransactions ──────────────────────────────────────────────────── + + @Test + void getAllTransactionsAsAdminReturnsAll() { + when(transactionRepository.findAll()).thenReturn(List.of( + txEntity(TX_ID, MEMBER_ID, REQUESTER_ID, 500), + txEntity(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), 100))); + + List result = service.getAllTransactions(REQUESTER_ID, true); + + assertThat(result).hasSize(2); + } + + @Test + void getAllTransactionsAsNonAdminReturnsOwnAndManagedDeduplicated() { + UUID managedMemberId = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + UUID asMemberTxId = UUID.fromString("00000000-0000-0000-0000-0000000000a1"); + UUID asCreatorTxId = UUID.fromString("00000000-0000-0000-0000-0000000000a2"); + UUID managedTxId = UUID.fromString("00000000-0000-0000-0000-0000000000a3"); + + // Requester is a director of "tennis", which contains managedMemberId. + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); + when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(managedMemberId))); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + TransactionEntity asMember = txEntity(asMemberTxId, REQUESTER_ID, UUID.randomUUID(), 100); + TransactionEntity asCreator = txEntity(asCreatorTxId, UUID.randomUUID(), REQUESTER_ID, 200); + TransactionEntity managed = txEntity(managedTxId, managedMemberId, UUID.randomUUID(), 300); + + when(transactionRepository.findAllByMemberId(REQUESTER_ID)).thenReturn(List.of(asMember)); + when(transactionRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(List.of(asCreator, asMember)); + when(transactionRepository.findAllByMemberId(managedMemberId)).thenReturn(List.of(managed)); + + List result = service.getAllTransactions(REQUESTER_ID, false); + + // asMember appears via both queries but must be returned once. + assertThat(result).extracting(Transaction::getId) + .containsExactlyInAnyOrder(asMemberTxId, asCreatorTxId, managedTxId); + } + + @Test + void getAllTransactionsAsNonAdminWithNothingReturnsEmpty() { + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(transactionRepository.findAllByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(transactionRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(List.of()); + + assertThat(service.getAllTransactions(REQUESTER_ID, false)).isEmpty(); + } + + // ── getTransaction ──────────────────────────────────────────────────────── + + @Test + void getTransactionNotFoundThrowsNotFoundException() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> service.getTransaction(TX_ID, REQUESTER_ID, false)); + } + + @Test + void getTransactionAsAdminReturnsAnyTransaction() { + UUID otherMember = UUID.randomUUID(); + UUID otherCreator = UUID.randomUUID(); + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, otherMember, otherCreator, 500))); + + Transaction result = service.getTransaction(TX_ID, REQUESTER_ID, true); + + assertThat(result.getId()).isEqualTo(TX_ID); + } + + @Test + void getTransactionAsMemberSubjectSucceeds() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, REQUESTER_ID, UUID.randomUUID(), 500))); + + assertThat(service.getTransaction(TX_ID, REQUESTER_ID, false).getId()).isEqualTo(TX_ID); + } + + @Test + void getTransactionAsCreatorSucceeds() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, UUID.randomUUID(), REQUESTER_ID, 500))); + + assertThat(service.getTransaction(TX_ID, REQUESTER_ID, false).getId()).isEqualTo(TX_ID); + } + + @Test + void getTransactionAsUnrelatedUserThrowsForbidden() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + assertThrows(ForbiddenException.class, () -> service.getTransaction(TX_ID, REQUESTER_ID, false)); + } + + // ── deleteTransaction ───────────────────────────────────────────────────── + + @Test + void deleteTransactionNotFoundThrowsNotFoundException() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> service.deleteTransaction(TX_ID, REQUESTER_ID, false)); + } + + @Test + void deleteTransactionAsCreatorSucceeds() { + TransactionEntity entity = txEntity(TX_ID, MEMBER_ID, REQUESTER_ID, 500); + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(entity)); + + service.deleteTransaction(TX_ID, REQUESTER_ID, false); + + verify(transactionRepository).delete(entity); + } + + @Test + void deleteTransactionAsAdminSucceeds() { + TransactionEntity entity = txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500); + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(entity)); + + service.deleteTransaction(TX_ID, REQUESTER_ID, true); + + verify(transactionRepository).delete(entity); + } + + @Test + void deleteTransactionAsNonCreatorThrowsForbidden() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + + assertThrows(ForbiddenException.class, () -> service.deleteTransaction(TX_ID, REQUESTER_ID, false)); + verify(transactionRepository, never()).delete(any()); + } + + // ── updateTransaction ───────────────────────────────────────────────────── + + @Test + void updateTransactionNotFoundThrowsNotFoundException() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, + () -> service.updateTransaction(TX_ID, new TransactionPartialUpdate().title("x"), REQUESTER_ID, false)); + } + + @Test + void updateTransactionAsNonCreatorThrowsForbidden() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + + assertThrows(ForbiddenException.class, + () -> service.updateTransaction(TX_ID, new TransactionPartialUpdate().title("x"), REQUESTER_ID, false)); + } + + @Test + void updateTransactionAsCreatorUpdatesEditableFields() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, REQUESTER_ID, 500))); + when(transactionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Transaction result = service.updateTransaction(TX_ID, + new TransactionPartialUpdate().amountCents(999).title("Updated").description("note"), + REQUESTER_ID, false); + + assertThat(result.getAmountCents()).isEqualTo(999); + assertThat(result.getTitle()).isEqualTo("Updated"); + assertThat(result.getDescription()).isEqualTo("note"); + assertThat(result.getMember()).isEqualTo(MEMBER_ID.toString()); + } + + @Test + void updateTransactionCreatorChangingMemberThrowsForbidden() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, REQUESTER_ID, 500))); + + assertThrows(ForbiddenException.class, + () -> service.updateTransaction(TX_ID, + new TransactionPartialUpdate().member(UUID.randomUUID().toString()), REQUESTER_ID, false)); + verify(transactionRepository, never()).save(any()); + } + + @Test + void updateTransactionAdminChangesMemberToExistingMember() { + UUID newMemberId = UUID.fromString("00000000-0000-0000-0000-0000000000bb"); + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + when(memberRepository.findById(newMemberId)).thenReturn(Optional.of(new MemberEntity())); + when(transactionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Transaction result = service.updateTransaction(TX_ID, + new TransactionPartialUpdate().member(newMemberId.toString()), REQUESTER_ID, true); + + assertThat(result.getMember()).isEqualTo(newMemberId.toString()); + } + + @Test + void updateTransactionAdminChangesMemberWithInvalidUuidThrowsBadRequest() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + + assertThrows(BadRequestException.class, + () -> service.updateTransaction(TX_ID, + new TransactionPartialUpdate().member("not-a-uuid"), REQUESTER_ID, true)); + } + + @Test + void updateTransactionAdminChangesMemberToNonExistentMemberThrowsNotFound() { + UUID newMemberId = UUID.fromString("00000000-0000-0000-0000-0000000000cc"); + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); + when(memberRepository.findById(newMemberId)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, + () -> service.updateTransaction(TX_ID, + new TransactionPartialUpdate().member(newMemberId.toString()), REQUESTER_ID, true)); + } + + @Test + void updateTransactionWithAllNullFieldsLeavesEntityUnchanged() { + when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, REQUESTER_ID, 500))); + when(transactionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Transaction result = service.updateTransaction(TX_ID, new TransactionPartialUpdate(), REQUESTER_ID, false); + + assertThat(result.getAmountCents()).isEqualTo(500); + assertThat(result.getTitle()).isEqualTo("Membership fee"); + assertThat(result.getMember()).isEqualTo(MEMBER_ID.toString()); + } + + // ── getAllBalances ──────────────────────────────────────────────────────── + + @Test + void getAllBalancesAsAdminSumsTransactionsPerMember() { + UUID otherMember = UUID.fromString("00000000-0000-0000-0000-0000000000dd"); + when(transactionRepository.findAll()).thenReturn(List.of( + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 100), + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 200), + txEntity(UUID.randomUUID(), otherMember, REQUESTER_ID, 50))); + + List result = service.getAllBalances(REQUESTER_ID, true); + + assertThat(result).extracting(Balance::getMember, Balance::getBalanceCents) + .containsExactlyInAnyOrder( + tuple(MEMBER_ID.toString(), 300), + tuple(otherMember.toString(), 50)); + } + + @Test + void getAllBalancesAsNonAdminWithNoManagedMembersThrowsForbidden() { + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + assertThrows(ForbiddenException.class, () -> service.getAllBalances(REQUESTER_ID, false)); + } + + @Test + void getAllBalancesAsDirectorSumsManagedMembersOnly() { + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); + when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 100), + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, -30))); + + List result = service.getAllBalances(REQUESTER_ID, false); + + assertThat(result).extracting(Balance::getMember, Balance::getBalanceCents) + .containsExactly(tuple(MEMBER_ID.toString(), 70)); + } + + // ── getMemberBalance ────────────────────────────────────────────────────── + + @Test + void getMemberBalanceMemberNotFoundThrowsNotFoundException() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> service.getMemberBalance(MEMBER_ID, REQUESTER_ID, false)); + } + + @Test + void getMemberBalanceAsSelfReturnsSum() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); + when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 100), + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 250))); + + Balance result = service.getMemberBalance(MEMBER_ID, MEMBER_ID, false); + + assertThat(result.getMember()).isEqualTo(MEMBER_ID.toString()); + assertThat(result.getBalanceCents()).isEqualTo(350); + } + + @Test + void getMemberBalanceAsAdminReturnsSum() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); + when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 500))); + + assertThat(service.getMemberBalance(MEMBER_ID, REQUESTER_ID, true).getBalanceCents()).isEqualTo(500); + } + + @Test + void getMemberBalanceAsDirectorOfMemberReturnsSum() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); + when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( + txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 42))); + + assertThat(service.getMemberBalance(MEMBER_ID, REQUESTER_ID, false).getBalanceCents()).isEqualTo(42); + } + + @Test + void getMemberBalanceAsUnrelatedUserThrowsForbidden() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); + when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); + + assertThrows(ForbiddenException.class, () -> service.getMemberBalance(MEMBER_ID, REQUESTER_ID, false)); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private TransactionEntity txEntity(UUID id, UUID memberId, UUID creatorId, int amountCents) { + TransactionEntity e = new TransactionEntity(); + e.setId(id); + e.setMemberId(memberId); + e.setCreatorId(creatorId); + e.setAmountCents(amountCents); + e.setCreatedAt(Instant.now()); + e.setTitle("Membership fee"); + e.setDescription(""); + return e; + } + + private TransactionCreate validRequest() { + return new TransactionCreate(MEMBER_ID.toString(), 500, "Membership fee"); + } + + private void memberExists() { + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); + } + + private TransactionEntity savedEntity() { + TransactionEntity e = new TransactionEntity(); + e.setId(TX_ID); + e.setMemberId(MEMBER_ID); + e.setCreatorId(REQUESTER_ID); + e.setAmountCents(500); + e.setCreatedAt(Instant.now()); + e.setTitle("Membership fee"); + e.setDescription(""); + return e; + } + + private TraineeEntity trainee(UUID memberId) { + return new TraineeEntity(new TraineeEntity.Id(TEAM_ID, memberId)); + } + + private TeamEntity mockTeam(UUID teamId) { + TeamEntity team = mock(TeamEntity.class); + when(team.getId()).thenReturn(teamId); + return team; + } +}