Hide adopted pets
This commit is contained in:
@@ -16,4 +16,16 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
|
|||||||
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
|
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
|
||||||
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
|
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
|
||||||
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
|
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Pet p WHERE LOWER(p.petStatus) = 'available' AND " +
|
||||||
|
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||||
|
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species))")
|
||||||
|
Page<Pet> searchPublicPets(@Param("q") String query, @Param("species") String species, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT p FROM Pet p LEFT JOIN Adoption a ON a.pet = p AND LOWER(a.adoptionStatus) = 'completed' WHERE " +
|
||||||
|
"(LOWER(p.petStatus) = 'available' OR a.customer.userId = :userId) AND " +
|
||||||
|
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||||
|
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
|
||||||
|
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
|
||||||
|
Page<Pet> searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import com.petshop.backend.entity.Adoption;
|
|||||||
import com.petshop.backend.entity.Pet;
|
import com.petshop.backend.entity.Pet;
|
||||||
import com.petshop.backend.entity.User;
|
import com.petshop.backend.entity.User;
|
||||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||||
|
import com.petshop.backend.security.AppPrincipal;
|
||||||
import com.petshop.backend.repository.AdoptionRepository;
|
import com.petshop.backend.repository.AdoptionRepository;
|
||||||
import com.petshop.backend.repository.PetRepository;
|
import com.petshop.backend.repository.PetRepository;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -34,13 +38,38 @@ public class PetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
|
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
|
||||||
return petRepository.searchPets(normalizeFilter(query), normalizeFilter(species), normalizeFilter(status), pageable)
|
String normalizedQuery = normalizeFilter(query);
|
||||||
|
String normalizedSpecies = normalizeFilter(species);
|
||||||
|
String normalizedStatus = normalizeFilter(status);
|
||||||
|
CurrentViewer viewer = getCurrentViewer();
|
||||||
|
|
||||||
|
Page<Pet> pets;
|
||||||
|
if (viewer == null) {
|
||||||
|
if (!isAllowedPublicStatus(normalizedStatus)) {
|
||||||
|
return new PageImpl<>(java.util.List.of(), pageable, 0);
|
||||||
|
}
|
||||||
|
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
|
||||||
|
} else if (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN) {
|
||||||
|
pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
|
||||||
|
} else if (viewer.role() == User.Role.CUSTOMER) {
|
||||||
|
if (!isAllowedCustomerStatus(normalizedStatus)) {
|
||||||
|
return new PageImpl<>(java.util.List.of(), pageable, 0);
|
||||||
|
}
|
||||||
|
pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
|
||||||
|
} else {
|
||||||
|
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pets
|
||||||
.map(this::mapToResponse);
|
.map(this::mapToResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public PetResponse getPetById(Long id) {
|
public PetResponse getPetById(Long id) {
|
||||||
Pet pet = petRepository.findById(id)
|
Pet pet = petRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||||
|
if (!canViewPet(pet, getCurrentViewer())) {
|
||||||
|
throw new ResourceNotFoundException("Pet not found with id: " + id);
|
||||||
|
}
|
||||||
return mapToResponse(pet);
|
return mapToResponse(pet);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +139,7 @@ public class PetService {
|
|||||||
if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) {
|
if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) {
|
||||||
throw new ResourceNotFoundException("Pet image not found for id: " + id);
|
throw new ResourceNotFoundException("Pet image not found for id: " + id);
|
||||||
}
|
}
|
||||||
if (!canViewPetImage(pet, requesterUserId, requesterRole)) {
|
if (!canViewPet(pet, new CurrentViewer(requesterUserId, requesterRole))) {
|
||||||
throw new ForbiddenImageAccessException();
|
throw new ForbiddenImageAccessException();
|
||||||
}
|
}
|
||||||
Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl());
|
Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl());
|
||||||
@@ -122,14 +151,21 @@ public class PetService {
|
|||||||
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
|
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) {
|
private boolean canViewPet(Pet pet, CurrentViewer viewer) {
|
||||||
if (isPubliclyVisible(pet)) {
|
if (isPubliclyVisible(pet)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) {
|
if (viewer != null && (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (requesterUserId == null) {
|
if (viewer == null || viewer.userId() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isAdoptedByUser(pet, viewer.userId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdoptedByUser(Pet pet, Long userId) {
|
||||||
|
if (userId == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
|
if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
|
||||||
@@ -137,10 +173,22 @@ public class PetService {
|
|||||||
}
|
}
|
||||||
return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed")
|
return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed")
|
||||||
.map(Adoption::getCustomer)
|
.map(Adoption::getCustomer)
|
||||||
.map(customer -> requesterUserId.equals(customer.getUserId()))
|
.map(customer -> userId.equals(customer.getUserId()))
|
||||||
.orElse(false);
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CurrentViewer getCurrentViewer() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object principal = authentication.getPrincipal();
|
||||||
|
if (principal instanceof AppPrincipal appPrincipal) {
|
||||||
|
return new CurrentViewer(appPrincipal.getUserId(), appPrincipal.getRole());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private Pet findPet(Long id) {
|
private Pet findPet(Long id) {
|
||||||
return petRepository.findById(id)
|
return petRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||||
@@ -177,6 +225,14 @@ public class PetService {
|
|||||||
return status == null ? "" : status.trim();
|
return status == null ? "" : status.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedPublicStatus(String status) {
|
||||||
|
return status == null || "available".equalsIgnoreCase(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedCustomerStatus(String status) {
|
||||||
|
return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status);
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeFilter(String value) {
|
private String normalizeFilter(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -203,6 +259,9 @@ public class PetService {
|
|||||||
public record ImagePayload(Resource resource, MediaType mediaType) {
|
public record ImagePayload(Resource resource, MediaType mediaType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record CurrentViewer(Long userId, User.Role role) {
|
||||||
|
}
|
||||||
|
|
||||||
public static class ForbiddenImageAccessException extends RuntimeException {
|
public static class ForbiddenImageAccessException extends RuntimeException {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.petshop.backend.service;
|
||||||
|
|
||||||
|
import com.petshop.backend.entity.Adoption;
|
||||||
|
import com.petshop.backend.entity.Customer;
|
||||||
|
import com.petshop.backend.entity.Pet;
|
||||||
|
import com.petshop.backend.entity.User;
|
||||||
|
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||||
|
import com.petshop.backend.repository.AdoptionRepository;
|
||||||
|
import com.petshop.backend.repository.PetRepository;
|
||||||
|
import com.petshop.backend.security.AppPrincipal;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
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 org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PetServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PetRepository petRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AdoptionRepository adoptionRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CatalogImageStorageService catalogImageStorageService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PetService petService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllPetsAnonymousReturnsOnlyPublicPets() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
Pet availablePet = pet(1L, "Buddy", "Available");
|
||||||
|
when(petRepository.searchPublicPets(null, null, pageable)).thenReturn(new PageImpl<>(List.of(availablePet), pageable, 1));
|
||||||
|
|
||||||
|
var result = petService.getAllPets(null, null, null, pageable);
|
||||||
|
|
||||||
|
assertEquals(1, result.getTotalElements());
|
||||||
|
assertEquals("Buddy", result.getContent().get(0).getPetName());
|
||||||
|
verify(petRepository).searchPublicPets(null, null, pageable);
|
||||||
|
verify(petRepository, never()).searchPets(null, null, null, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllPetsAnonymousWithAdoptedStatusReturnsEmptyPage() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
|
||||||
|
var result = petService.getAllPets(null, null, "Adopted", pageable);
|
||||||
|
|
||||||
|
assertEquals(0, result.getTotalElements());
|
||||||
|
verify(petRepository, never()).searchPublicPets(null, null, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllPetsCustomerReturnsVisiblePetsOnly() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
setAuthentication(25L, User.Role.CUSTOMER);
|
||||||
|
Pet availablePet = pet(1L, "Buddy", "Available");
|
||||||
|
Pet adoptedPet = pet(2L, "Luna", "Adopted");
|
||||||
|
when(petRepository.searchCustomerVisiblePets(25L, null, null, null, pageable))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
|
||||||
|
|
||||||
|
var result = petService.getAllPets(null, null, null, pageable);
|
||||||
|
|
||||||
|
assertEquals(2, result.getTotalElements());
|
||||||
|
verify(petRepository).searchCustomerVisiblePets(25L, null, null, null, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllPetsAdminReturnsAllPets() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
setAuthentication(99L, User.Role.ADMIN);
|
||||||
|
Pet availablePet = pet(1L, "Buddy", "Available");
|
||||||
|
Pet adoptedPet = pet(2L, "Luna", "Adopted");
|
||||||
|
when(petRepository.searchPets(null, null, null, pageable))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
|
||||||
|
|
||||||
|
var result = petService.getAllPets(null, null, null, pageable);
|
||||||
|
|
||||||
|
assertEquals(2, result.getTotalElements());
|
||||||
|
verify(petRepository).searchPets(null, null, null, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPetByIdHidesAdoptedPetFromUnrelatedCustomer() {
|
||||||
|
setAuthentication(50L, User.Role.CUSTOMER);
|
||||||
|
Pet adoptedPet = pet(2L, "Luna", "Adopted");
|
||||||
|
when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet));
|
||||||
|
when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed"))
|
||||||
|
.thenReturn(Optional.of(adoption(2L, 25L)));
|
||||||
|
|
||||||
|
assertThrows(ResourceNotFoundException.class, () -> petService.getPetById(2L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPetByIdAllowsOwnerToSeeAdoptedPet() {
|
||||||
|
setAuthentication(25L, User.Role.CUSTOMER);
|
||||||
|
Pet adoptedPet = pet(2L, "Luna", "Adopted");
|
||||||
|
when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet));
|
||||||
|
when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed"))
|
||||||
|
.thenReturn(Optional.of(adoption(2L, 25L)));
|
||||||
|
|
||||||
|
var result = petService.getPetById(2L);
|
||||||
|
|
||||||
|
assertEquals(2L, result.getPetId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setAuthentication(Long userId, User.Role role) {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
new AppPrincipal(userId, "user", role, 0),
|
||||||
|
"n/a",
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pet pet(Long id, String name, String status) {
|
||||||
|
Pet pet = new Pet();
|
||||||
|
pet.setPetId(id);
|
||||||
|
pet.setPetName(name);
|
||||||
|
pet.setPetSpecies("Cat");
|
||||||
|
pet.setPetBreed("Mixed");
|
||||||
|
pet.setPetAge(2);
|
||||||
|
pet.setPetStatus(status);
|
||||||
|
pet.setPetPrice(java.math.BigDecimal.TEN);
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Adoption adoption(Long petId, Long userId) {
|
||||||
|
Adoption adoption = new Adoption();
|
||||||
|
Pet pet = new Pet();
|
||||||
|
pet.setPetId(petId);
|
||||||
|
adoption.setPet(pet);
|
||||||
|
Customer customer = new Customer();
|
||||||
|
customer.setCustomerId(1L);
|
||||||
|
customer.setUserId(userId);
|
||||||
|
adoption.setCustomer(customer);
|
||||||
|
adoption.setAdoptionStatus("Completed");
|
||||||
|
return adoption;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user