From 9a110d377f06127a8143c4fa232ad776a5404a55 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Thu, 2 Apr 2026 09:54:32 -0600 Subject: [PATCH 1/3] Hide adopted pets --- .../backend/repository/PetRepository.java | 12 ++ .../petshop/backend/service/PetService.java | 71 +++++++- .../backend/service/PetServiceTest.java | 165 ++++++++++++++++++ 3 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 backend/src/test/java/com/petshop/backend/service/PetServiceTest.java diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index a474fa8b..d974deb3 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -16,4 +16,16 @@ public interface PetRepository extends JpaRepository { "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))") Page 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 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 searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable); } diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index 4672ee85..dc5fa61e 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -7,12 +7,16 @@ import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.security.AppPrincipal; import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.PetRepository; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; 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.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -34,13 +38,38 @@ public class PetService { } public Page 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 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); } public PetResponse getPetById(Long id) { Pet pet = petRepository.findById(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); } @@ -110,7 +139,7 @@ public class PetService { if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { 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(); } Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); @@ -122,14 +151,21 @@ public class PetService { 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)) { 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; } - 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; } if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) { @@ -137,10 +173,22 @@ public class PetService { } return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed") .map(Adoption::getCustomer) - .map(customer -> requesterUserId.equals(customer.getUserId())) + .map(customer -> userId.equals(customer.getUserId())) .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) { return petRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); @@ -177,6 +225,14 @@ public class PetService { 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) { if (value == null) { return null; @@ -203,6 +259,9 @@ public class PetService { public record ImagePayload(Resource resource, MediaType mediaType) { } + private record CurrentViewer(Long userId, User.Role role) { + } + public static class ForbiddenImageAccessException extends RuntimeException { } } diff --git a/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java b/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java new file mode 100644 index 00000000..9107ebd9 --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/PetServiceTest.java @@ -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; + } +} -- 2.49.1 From 109f9674354a6cb6f94d5351c7e35382932a5f47 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Thu, 2 Apr 2026 10:10:04 -0600 Subject: [PATCH 2/3] Fix appointment ownership --- .../controller/DropdownController.java | 27 ++++- .../repository/CustomerPetRepository.java | 2 + .../backend/service/AppointmentService.java | 9 +- .../service/AppointmentServiceTest.java | 111 ++++++++++++++++-- .../dto/appointment/AppointmentRequest.java | 9 ++ .../dto/appointment/AppointmentResponse.java | 18 +++ .../api/endpoints/DropdownApi.java | 8 ++ .../controllers/AppointmentController.java | 9 +- .../AppointmentDialogController.java | 59 ++++++++-- 9 files changed, 226 insertions(+), 26 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java index c942eae8..56e53a56 100644 --- a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java +++ b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java @@ -1,10 +1,12 @@ package com.petshop.backend.controller; import com.petshop.backend.dto.common.DropdownOption; +import com.petshop.backend.entity.CustomerPet; import com.petshop.backend.repository.*; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,6 +19,7 @@ public class DropdownController { private final PetRepository petRepository; private final CustomerRepository customerRepository; + private final CustomerPetRepository customerPetRepository; private final ServiceRepository serviceRepository; private final ProductRepository productRepository; private final CategoryRepository categoryRepository; @@ -24,11 +27,13 @@ public class DropdownController { private final SupplierRepository supplierRepository; public DropdownController(PetRepository petRepository, CustomerRepository customerRepository, - ServiceRepository serviceRepository, ProductRepository productRepository, - CategoryRepository categoryRepository, StoreRepository storeRepository, - SupplierRepository supplierRepository) { + CustomerPetRepository customerPetRepository, + ServiceRepository serviceRepository, ProductRepository productRepository, + CategoryRepository categoryRepository, StoreRepository storeRepository, + SupplierRepository supplierRepository) { this.petRepository = petRepository; this.customerRepository = customerRepository; + this.customerPetRepository = customerPetRepository; this.serviceRepository = serviceRepository; this.productRepository = productRepository; this.categoryRepository = categoryRepository; @@ -55,6 +60,16 @@ public class DropdownController { ); } + @GetMapping("/customers/{customerId}/pets") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity> getCustomerPets(@PathVariable Long customerId) { + return ResponseEntity.ok( + customerPetRepository.findByCustomerCustomerIdOrderByPetNameAsc(customerId).stream() + .map(this::toCustomerPetOption) + .collect(Collectors.toList()) + ); + } + @GetMapping("/services") public ResponseEntity> getServices() { return ResponseEntity.ok( @@ -123,4 +138,10 @@ public class DropdownController { .collect(Collectors.toList()) ); } + + private DropdownOption toCustomerPetOption(CustomerPet pet) { + String species = pet.getSpecies() == null || pet.getSpecies().isBlank() ? "Pet" : pet.getSpecies(); + String breed = pet.getBreed() == null || pet.getBreed().isBlank() ? "" : " ยท " + pet.getBreed(); + return new DropdownOption(pet.getCustomerPetId(), pet.getPetName() + " (" + species + breed + ")"); + } } diff --git a/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java index 8d08f8b9..4fe0ef81 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java @@ -12,5 +12,7 @@ public interface CustomerPetRepository extends JpaRepository List findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId); + List findByCustomerCustomerIdOrderByPetNameAsc(Long customerId); + Optional findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId); } diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index 67ce4f36..c5b615d3 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -120,7 +120,7 @@ public class AppointmentService { } Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); - Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); + Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>(); Appointment appointment = new Appointment(); appointment.setCustomer(customer); @@ -164,7 +164,7 @@ public class AppointmentService { } Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); - Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); + Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>(); appointment.setCustomer(customer); appointment.setStore(store); @@ -247,11 +247,14 @@ public class AppointmentService { return pets; } - private Set fetchCustomerPets(List customerPetIds) { + private Set fetchCustomerPets(List customerPetIds, Long customerId) { Set customerPets = new HashSet<>(); for (Long customerPetId : customerPetIds) { CustomerPet customerPet = customerPetRepository.findById(customerPetId) .orElseThrow(() -> new ResourceNotFoundException("Customer pet not found with id: " + customerPetId)); + if (!customerPet.getCustomer().getCustomerId().equals(customerId)) { + throw new IllegalArgumentException("Selected pet does not belong to the selected customer"); + } customerPets.add(customerPet); } diff --git a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java index 2a1e6eed..e978fcde 100644 --- a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java @@ -2,12 +2,16 @@ package com.petshop.backend.service; import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.CustomerPet; import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.Service; import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; import com.petshop.backend.repository.AppointmentRepository; +import com.petshop.backend.repository.CustomerPetRepository; import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.EmployeeRepository; +import com.petshop.backend.repository.EmployeeStoreRepository; import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.ServiceRepository; import com.petshop.backend.repository.StoreRepository; @@ -29,6 +33,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -44,6 +49,9 @@ class AppointmentServiceTest { @Mock private CustomerRepository customerRepository; + @Mock + private CustomerPetRepository customerPetRepository; + @Mock private PetRepository petRepository; @@ -56,6 +64,12 @@ class AppointmentServiceTest { @Mock private UserRepository userRepository; + @Mock + private EmployeeRepository employeeRepository; + + @Mock + private EmployeeStoreRepository employeeStoreRepository; + @InjectMocks private AppointmentService appointmentService; @@ -64,6 +78,7 @@ class AppointmentServiceTest { private Service grooming; private Service nailTrim; private Pet pet; + private CustomerPet customerPet; private LocalDate date; @BeforeEach @@ -91,6 +106,11 @@ class AppointmentServiceTest { pet.setPetId(1L); pet.setPetName("Milo"); + customerPet = new CustomerPet(); + customerPet.setCustomerPetId(11L); + customerPet.setPetName("Milo Jr"); + customerPet.setCustomer(customer); + date = LocalDate.now().plusDays(1); } @@ -144,14 +164,7 @@ class AppointmentServiceTest { user.setRole(User.Role.CUSTOMER); user.setTokenVersion(0); when(userRepository.findById(10L)).thenReturn(Optional.of(user)); - - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken( - new com.petshop.backend.security.AppPrincipal(10L, "pat", User.Role.CUSTOMER, 0), - "n/a", - List.of(new SimpleGrantedAuthority("ROLE_CUSTOMER")) - ) - ); + setAuthentication(10L, User.Role.CUSTOMER); when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing)); when(customerRepository.findById(1L)).thenReturn(Optional.of(customer)); @@ -176,6 +189,78 @@ class AppointmentServiceTest { assertEquals("Booked", response.getAppointmentStatus()); } + @Test + void createAppointmentRejectsCustomerPetOwnedByDifferentCustomer() { + User adminUser = new User(); + adminUser.setId(99L); + adminUser.setUsername("admin"); + adminUser.setRole(User.Role.ADMIN); + adminUser.setTokenVersion(0); + when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser)); + setAuthentication(99L, User.Role.ADMIN); + + Customer otherCustomer = new Customer(); + otherCustomer.setCustomerId(2L); + + CustomerPet otherCustomerPet = new CustomerPet(); + otherCustomerPet.setCustomerPetId(22L); + otherCustomerPet.setCustomer(otherCustomer); + otherCustomerPet.setPetName("Not Yours"); + + when(customerRepository.findById(1L)).thenReturn(Optional.of(customer)); + when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); + when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet)); + + var request = new com.petshop.backend.dto.appointment.AppointmentRequest(); + request.setCustomerId(1L); + request.setStoreId(1L); + request.setServiceId(1L); + request.setAppointmentDate(date); + request.setAppointmentTime(LocalTime.of(10, 0)); + request.setAppointmentStatus("Booked"); + request.setCustomerPetIds(List.of(22L)); + + assertThrows(IllegalArgumentException.class, () -> appointmentService.createAppointment(request)); + } + + @Test + void createAppointmentAllowsCustomerOwnedCustomerPet() { + User adminUser = new User(); + adminUser.setId(99L); + adminUser.setUsername("admin"); + adminUser.setRole(User.Role.ADMIN); + adminUser.setTokenVersion(0); + when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser)); + setAuthentication(99L, User.Role.ADMIN); + + when(customerRepository.findById(1L)).thenReturn(Optional.of(customer)); + when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); + when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet)); + when(appointmentRepository.save(any(Appointment.class))).thenAnswer(invocation -> { + Appointment appointment = invocation.getArgument(0); + appointment.setAppointmentId(99L); + return appointment; + }); + + var request = new com.petshop.backend.dto.appointment.AppointmentRequest(); + request.setCustomerId(1L); + request.setStoreId(1L); + request.setServiceId(1L); + request.setAppointmentDate(date); + request.setAppointmentTime(LocalTime.of(10, 0)); + request.setAppointmentStatus("Booked"); + request.setCustomerPetIds(List.of(11L)); + + var response = appointmentService.createAppointment(request); + + assertEquals(99L, response.getAppointmentId()); + assertEquals(1L, response.getCustomerId()); + } + private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) { Appointment appointment = new Appointment(); appointment.setAppointmentId(id); @@ -188,4 +273,14 @@ class AppointmentServiceTest { appointment.setPets(Set.of()); return appointment; } + + private void setAuthentication(Long userId, User.Role role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + new com.petshop.backend.security.AppPrincipal(userId, "user", role, 0), + "n/a", + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ) + ); + } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java index a81faaff..299fcae9 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java @@ -6,6 +6,7 @@ import java.util.List; public class AppointmentRequest { private List petIds; + private List customerPetIds; private Long customerId; private Long storeId; private Long serviceId; @@ -24,6 +25,14 @@ public class AppointmentRequest { this.petIds = petIds; } + public List getCustomerPetIds() { + return customerPetIds; + } + + public void setCustomerPetIds(List customerPetIds) { + this.customerPetIds = customerPetIds; + } + public Long getCustomerId() { return customerId; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java index 1d904bd0..c71dc3f3 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java @@ -12,6 +12,8 @@ public class AppointmentResponse { private Long serviceId; private java.util.List petNames; private java.util.List petIds; + private java.util.List customerPetNames; + private java.util.List customerPetIds; private String serviceName; private LocalDate appointmentDate; private LocalTime appointmentTime; @@ -84,6 +86,22 @@ public class AppointmentResponse { this.petIds = petIds; } + public java.util.List getCustomerPetNames() { + return customerPetNames; + } + + public void setCustomerPetNames(java.util.List customerPetNames) { + this.customerPetNames = customerPetNames; + } + + public java.util.List getCustomerPetIds() { + return customerPetIds; + } + + public void setCustomerPetIds(java.util.List customerPetIds) { + this.customerPetIds = customerPetIds; + } + public String getServiceName() { return serviceName; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java index 30fcb0b8..ec0fe4d0 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java @@ -82,6 +82,14 @@ public class DropdownApi { return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); } + public List getCustomerPets(Long customerId) throws Exception { + String response = apiClient.getRawResponse("/api/v1/dropdowns/customers/" + customerId + "/pets"); + if (response == null || response.isEmpty()) { + throw new IllegalStateException("Empty response from customer pets endpoint"); + } + return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); + } + public List getStores() throws Exception { String response = apiClient.getRawResponse("/api/v1/dropdowns/stores"); if (response == null || response.isEmpty()) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java index bd5dc392..e310c039 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java @@ -233,13 +233,18 @@ public class AppointmentController { } private AppointmentDTO mapToAppointmentDTO(AppointmentResponse response) { - Long petId = response.getPetIds() != null && !response.getPetIds().isEmpty() ? response.getPetIds().get(0) : null; + Long petId = response.getCustomerPetIds() != null && !response.getCustomerPetIds().isEmpty() + ? response.getCustomerPetIds().get(0) + : response.getPetIds() != null && !response.getPetIds().isEmpty() ? response.getPetIds().get(0) : null; + String petName = response.getCustomerPetNames() != null && !response.getCustomerPetNames().isEmpty() + ? String.join(", ", response.getCustomerPetNames()) + : String.join(", ", response.getPetNames()); return new AppointmentDTO( response.getAppointmentId().intValue(), response.getCustomerId() != null ? response.getCustomerId().intValue() : 0, response.getCustomerName(), petId != null ? petId.intValue() : 0, - String.join(", ", response.getPetNames()), + petName, response.getServiceId() != null ? response.getServiceId().intValue() : 0, response.getServiceName(), response.getAppointmentDate().toString(), diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java index 932d10d5..c4b39ba7 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java @@ -50,6 +50,7 @@ public class AppointmentDialogController { private String mode = null; // Add | Edit private AppointmentDTO selectedAppointment = null; + private Long pendingPetSelectionId = null; private ObservableList statusList = FXCollections.observableArrayList( @@ -77,7 +78,6 @@ public class AppointmentDialogController { try { List services = DropdownApi.getInstance().getServices(); List customers = DropdownApi.getInstance().getCustomers(); - List pets = DropdownApi.getInstance().getPets(); Platform.runLater(() -> { if (services != null) { @@ -86,9 +86,6 @@ public class AppointmentDialogController { if (customers != null) { cbCustomer.setItems(FXCollections.observableArrayList(customers)); } - if (pets != null) { - cbPet.setItems(FXCollections.observableArrayList(pets)); - } syncSelectedAppointment(); }); } catch (Exception e) { @@ -103,6 +100,7 @@ public class AppointmentDialogController { }).start(); cbAppointmentStatus.setItems(statusList); + cbPet.setDisable(true); // Hours 9 AM - 5 PM for (int i = 9; i <= 17; i++) { @@ -157,6 +155,18 @@ public class AppointmentDialogController { } }); + cbCustomer.valueProperty().addListener((obs, oldValue, newValue) -> { + Long customerId = newValue != null ? newValue.getId() : null; + cbPet.setValue(null); + cbPet.setItems(FXCollections.observableArrayList()); + cbPet.setDisable(customerId == null); + if (customerId != null) { + loadCustomerPets(customerId); + } else { + pendingPetSelectionId = null; + } + }); + btnSave.setOnMouseClicked(this::buttonSaveClicked); btnCancel.setOnMouseClicked(this::closeStage); } @@ -199,11 +209,10 @@ public class AppointmentDialogController { }); cbCustomer.getItems().forEach(c -> { - if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) cbCustomer.setValue(c); - }); - - cbPet.getItems().forEach(p -> { - if (p.getId() != null && p.getId().longValue() == appt.getPetId()) cbPet.setValue(p); + if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) { + pendingPetSelectionId = (long) appt.getPetId(); + cbCustomer.setValue(c); + } }); } @@ -233,7 +242,7 @@ public class AppointmentDialogController { } AppointmentRequest request = new AppointmentRequest(); - request.setPetIds(Collections.singletonList(cbPet.getValue().getId())); + request.setCustomerPetIds(Collections.singletonList(cbPet.getValue().getId())); request.setCustomerId(cbCustomer.getValue().getId()); request.setStoreId(storeId); request.setServiceId(cbService.getValue().getId()); @@ -288,4 +297,34 @@ public class AppointmentDialogController { displayAppointmentDetails(selectedAppointment); } } + + private void loadCustomerPets(Long customerId) { + new Thread(() -> { + try { + List pets = DropdownApi.getInstance().getCustomerPets(customerId); + Platform.runLater(() -> { + cbPet.setItems(FXCollections.observableArrayList(pets)); + cbPet.setDisable(false); + if (pendingPetSelectionId != null) { + for (DropdownOption pet : cbPet.getItems()) { + if (pet.getId() != null && pet.getId().equals(pendingPetSelectionId)) { + cbPet.setValue(pet); + break; + } + } + pendingPetSelectionId = null; + } + }); + } catch (Exception ex) { + Platform.runLater(() -> { + ActivityLogger.getInstance().logException( + "AppointmentDialogController.loadCustomerPets", + ex, + "Loading customer pets for appointment dialog"); + cbPet.setDisable(true); + showError("Error loading pets for selected customer"); + }); + } + }).start(); + } } -- 2.49.1 From 1079abf0c522391ae56a744ec6f242e15447607c Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sat, 4 Apr 2026 16:23:28 -0600 Subject: [PATCH 3/3] Harden appointment dialog --- .../AppointmentDialogController.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java index c4b39ba7..81b54cb9 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java @@ -19,6 +19,7 @@ import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.util.ActivityLogger; import java.time.LocalTime; +import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -101,6 +102,11 @@ public class AppointmentDialogController { cbAppointmentStatus.setItems(statusList); cbPet.setDisable(true); + cbPet.setPromptText("Select a customer first"); + cbCustomer.setPromptText("Select a customer"); + cbService.setPromptText("Select a service"); + dpAppointmentDate.setValue(LocalDate.now().plusDays(1)); + cbAppointmentStatus.setValue("Booked"); // Hours 9 AM - 5 PM for (int i = 9; i <= 17; i++) { @@ -161,8 +167,10 @@ public class AppointmentDialogController { cbPet.setItems(FXCollections.observableArrayList()); cbPet.setDisable(customerId == null); if (customerId != null) { + cbPet.setPromptText("Loading customer pets..."); loadCustomerPets(customerId); } else { + cbPet.setPromptText("Select a customer first"); pendingPetSelectionId = null; } }); @@ -304,14 +312,25 @@ public class AppointmentDialogController { List pets = DropdownApi.getInstance().getCustomerPets(customerId); Platform.runLater(() -> { cbPet.setItems(FXCollections.observableArrayList(pets)); - cbPet.setDisable(false); + cbPet.setDisable(pets == null || pets.isEmpty()); + cbPet.setPromptText(pets == null || pets.isEmpty() ? "No pets for selected customer" : "Select a pet"); if (pendingPetSelectionId != null) { + boolean matched = false; for (DropdownOption pet : cbPet.getItems()) { if (pet.getId() != null && pet.getId().equals(pendingPetSelectionId)) { cbPet.setValue(pet); + matched = true; break; } } + if (!matched && selectedAppointment != null && selectedAppointment.getPetName() != null && !selectedAppointment.getPetName().isBlank()) { + DropdownOption legacy = new DropdownOption(); + legacy.setId(pendingPetSelectionId); + legacy.setLabel(selectedAppointment.getPetName() + " (legacy appointment pet)"); + cbPet.getItems().add(0, legacy); + cbPet.setValue(legacy); + cbPet.setDisable(false); + } pendingPetSelectionId = null; } }); @@ -322,6 +341,7 @@ public class AppointmentDialogController { ex, "Loading customer pets for appointment dialog"); cbPet.setDisable(true); + cbPet.setPromptText("Unable to load pets"); showError("Error loading pets for selected customer"); }); } -- 2.49.1