From ab97a86977ead74ce10b43ee3e435ca793a225b2 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 19:02:12 -0600 Subject: [PATCH 1/5] Add chat close endpoint --- .../backend/controller/ChatController.java | 9 ++ .../petshop/backend/service/ChatService.java | 30 +++++ .../backend/service/ChatServiceTest.java | 126 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index 8cfb5df3..59b4f74f 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -96,4 +96,13 @@ public class ChatController { chatRealtimeService.publishConversationUpdate(id); return ResponseEntity.ok(conversation); } + + @PostMapping("/conversations/{id}/close") + @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") + public ResponseEntity closeConversation(@PathVariable Long id) { + User user = getCurrentUser(); + ConversationResponse conversation = chatService.closeConversation(id, user.getId(), user.getRole()); + chatRealtimeService.publishConversationUpdate(id); + return ResponseEntity.ok(conversation); + } } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index f66cbdb5..08c94478 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -116,6 +116,10 @@ public class ChatService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) { + throw new AccessDeniedException("Conversation is closed"); + } + if (!hasConversationAccess(conversation, userId, role)) { if (role == User.Role.CUSTOMER) { throw new AccessDeniedException("You can only send messages to your own conversations"); @@ -149,6 +153,10 @@ public class ChatService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) { + throw new AccessDeniedException("Conversation is closed"); + } + if (role != User.Role.CUSTOMER || !hasConversationAccess(conversation, userId, role)) { throw new AccessDeniedException("You can only request human takeover for your own conversations"); } @@ -163,6 +171,28 @@ public class ChatService { return ConversationResponse.fromEntity(conversation, lastMessage); } + @Transactional + public ConversationResponse closeConversation(Long conversationId, Long userId, User.Role role) { + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + + if (!hasConversationAccess(conversation, userId, role)) { + if (role == User.Role.CUSTOMER) { + throw new AccessDeniedException("You can only close your own conversations"); + } + if (role == User.Role.STAFF) { + throw new AccessDeniedException("You can only close conversations assigned to you or unassigned conversations"); + } + } + + conversation.setStatus(Conversation.ConversationStatus.CLOSED); + conversation = conversationRepository.save(conversation); + + List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent(); + return ConversationResponse.fromEntity(conversation, lastMessage); + } + public List getMessages(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); diff --git a/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java b/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java new file mode 100644 index 00000000..323d4c04 --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java @@ -0,0 +1,126 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.chat.ConversationRequest; +import com.petshop.backend.dto.chat.MessageRequest; +import com.petshop.backend.entity.Conversation; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.Message; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.ConversationRepository; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.MessageRepository; +import com.petshop.backend.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; + +import java.time.LocalDateTime; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ChatServiceTest { + + @Mock + private ConversationRepository conversationRepository; + + @Mock + private MessageRepository messageRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private CustomerRepository customerRepository; + + @InjectMocks + private ChatService chatService; + + private Customer customer; + + @BeforeEach + void setUp() { + customer = new Customer(); + customer.setCustomerId(1L); + customer.setUserId(10L); + customer.setFirstName("Pat"); + customer.setLastName("Owner"); + customer.setEmail("pat@example.com"); + } + + @Test + void closeConversationMarksConversationClosed() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)) + .thenReturn(List.of(message("hello"))); + + var response = chatService.closeConversation(99L, 10L, User.Role.CUSTOMER); + + assertEquals("CLOSED", response.getStatus()); + assertEquals("hello", response.getLastMessage()); + verify(conversationRepository).save(conversation); + } + + @Test + void closeConversationRejectsOtherCustomer() { + Conversation conversation = conversation(99L, 2L, null, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); + + assertThrows(AccessDeniedException.class, () -> chatService.closeConversation(99L, 10L, User.Role.CUSTOMER)); + } + + @Test + void sendMessageRejectsClosedConversation() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + + assertThrows(AccessDeniedException.class, + () -> chatService.sendMessage(99L, 10L, User.Role.CUSTOMER, new MessageRequest("hello"))); + + verify(messageRepository, never()).save(any()); + } + + @Test + void requestHumanTakeoverRejectsClosedConversation() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + + assertThrows(AccessDeniedException.class, + () -> chatService.requestHumanTakeover(99L, 10L, User.Role.CUSTOMER)); + } + + private Conversation conversation(Long id, Long customerId, Long staffId, Conversation.ConversationStatus status) { + Conversation conversation = new Conversation(); + conversation.setId(id); + conversation.setCustomerId(customerId); + conversation.setStaffId(staffId); + conversation.setStatus(status); + conversation.setMode(Conversation.ConversationMode.AUTOMATED); + conversation.setHumanRequestedAt(LocalDateTime.now()); + return conversation; + } + + private Message message(String content) { + Message message = new Message(); + message.setConversationId(99L); + message.setSenderId(10L); + message.setContent(content); + message.setIsRead(false); + return message; + } +} -- 2.49.1 From 3b84eff536e8dd732ec346a62f5f9b2e9757e940 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 19:02:19 -0600 Subject: [PATCH 2/5] Fix appointment overlap rules --- .../service/AppointmentServiceTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java diff --git a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java new file mode 100644 index 00000000..fbc4ba4b --- /dev/null +++ b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java @@ -0,0 +1,112 @@ +package com.petshop.backend.service; + +import com.petshop.backend.entity.Appointment; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.Service; +import com.petshop.backend.entity.StoreLocation; +import com.petshop.backend.repository.AppointmentRepository; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.ServiceRepository; +import com.petshop.backend.repository.StoreRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AppointmentServiceTest { + + @Mock + private AppointmentRepository appointmentRepository; + + @Mock + private CustomerRepository customerRepository; + + @Mock + private ServiceRepository serviceRepository; + + @Mock + private StoreRepository storeRepository; + + @InjectMocks + private AppointmentService appointmentService; + + private Customer customer; + private StoreLocation store; + private Service grooming; + private Service nailTrim; + private LocalDate date; + + @BeforeEach + void setUp() { + customer = new Customer(); + customer.setCustomerId(1L); + customer.setFirstName("Pat"); + customer.setLastName("Owner"); + + store = new StoreLocation(); + store.setStoreId(1L); + store.setStoreName("Main Store"); + + grooming = new Service(); + grooming.setServiceId(1L); + grooming.setServiceName("Grooming"); + grooming.setServiceDuration(30); + + nailTrim = new Service(); + nailTrim.setServiceId(2L); + nailTrim.setServiceName("Nail Trim"); + nailTrim.setServiceDuration(30); + + date = LocalDate.now().plusDays(1); + } + + @Test + void checkAvailabilityAllowsDifferentServicesAtSameTime() { + Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store); + when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); + when(serviceRepository.findById(2L)).thenReturn(Optional.of(nailTrim)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing)); + + List slots = appointmentService.checkAvailability(1L, 2L, date); + + assertTrue(slots.contains("10:00")); + } + + @Test + void checkAvailabilityBlocksSameServiceAtSameTime() { + Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store); + when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); + when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing)); + + List slots = appointmentService.checkAvailability(1L, 1L, date); + + assertFalse(slots.contains("10:00")); + } + + private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) { + Appointment appointment = new Appointment(); + appointment.setAppointmentId(id); + appointment.setAppointmentDate(date); + appointment.setAppointmentTime(time); + appointment.setAppointmentStatus("Booked"); + appointment.setService(service); + appointment.setStore(storeLocation); + appointment.setCustomer(customer); + appointment.setPets(Set.of()); + return appointment; + } +} -- 2.49.1 From 36ac309442ecf39b463fc8eb8245a837f6fed7bd Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 21:07:10 -0600 Subject: [PATCH 3/5] Update chat conversation status --- .../backend/controller/ChatController.java | 7 +- .../dto/chat/UpdateConversationRequest.java | 25 ++++++ .../petshop/backend/service/ChatService.java | 10 ++- .../backend/service/ChatServiceTest.java | 83 +++++++++++++++++-- 4 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/dto/chat/UpdateConversationRequest.java diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index 59b4f74f..7320cdb9 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest; import com.petshop.backend.dto.chat.ConversationResponse; import com.petshop.backend.dto.chat.MessageRequest; import com.petshop.backend.dto.chat.MessageResponse; +import com.petshop.backend.dto.chat.UpdateConversationRequest; import com.petshop.backend.entity.User; import com.petshop.backend.repository.CustomerRepository; import com.petshop.backend.repository.UserRepository; @@ -97,11 +98,11 @@ public class ChatController { return ResponseEntity.ok(conversation); } - @PostMapping("/conversations/{id}/close") + @PutMapping("/conversations/{id}") @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") - public ResponseEntity closeConversation(@PathVariable Long id) { + public ResponseEntity updateConversation(@PathVariable Long id, @Valid @RequestBody UpdateConversationRequest request) { User user = getCurrentUser(); - ConversationResponse conversation = chatService.closeConversation(id, user.getId(), user.getRole()); + ConversationResponse conversation = chatService.updateConversation(id, user.getId(), user.getRole(), request); chatRealtimeService.publishConversationUpdate(id); return ResponseEntity.ok(conversation); } diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/UpdateConversationRequest.java b/backend/src/main/java/com/petshop/backend/dto/chat/UpdateConversationRequest.java new file mode 100644 index 00000000..4c043a79 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/chat/UpdateConversationRequest.java @@ -0,0 +1,25 @@ +package com.petshop.backend.dto.chat; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public class UpdateConversationRequest { + @NotBlank(message = "Status is required") + @Pattern(regexp = "^(OPEN|CLOSED)$", message = "Status must be OPEN or CLOSED") + private String status; + + public UpdateConversationRequest() { + } + + public UpdateConversationRequest(String status) { + this.status = status; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 08c94478..d9165c9d 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -4,6 +4,7 @@ import com.petshop.backend.dto.chat.ConversationRequest; import com.petshop.backend.dto.chat.ConversationResponse; import com.petshop.backend.dto.chat.MessageRequest; import com.petshop.backend.dto.chat.MessageResponse; +import com.petshop.backend.dto.chat.UpdateConversationRequest; import com.petshop.backend.entity.Conversation; import com.petshop.backend.entity.Customer; import com.petshop.backend.entity.Message; @@ -172,7 +173,7 @@ public class ChatService { } @Transactional - public ConversationResponse closeConversation(Long conversationId, Long userId, User.Role role) { + public ConversationResponse updateConversation(Long conversationId, Long userId, User.Role role, UpdateConversationRequest request) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); @@ -185,7 +186,7 @@ public class ChatService { } } - conversation.setStatus(Conversation.ConversationStatus.CLOSED); + conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus())); conversation = conversationRepository.save(conversation); List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); @@ -193,6 +194,11 @@ public class ChatService { return ConversationResponse.fromEntity(conversation, lastMessage); } + @Transactional + public ConversationResponse closeConversation(Long conversationId, Long userId, User.Role role) { + return updateConversation(conversationId, userId, role, new UpdateConversationRequest("CLOSED")); + } + public List getMessages(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); diff --git a/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java b/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java index 323d4c04..4ce13bd2 100644 --- a/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/ChatServiceTest.java @@ -1,7 +1,7 @@ package com.petshop.backend.service; -import com.petshop.backend.dto.chat.ConversationRequest; import com.petshop.backend.dto.chat.MessageRequest; +import com.petshop.backend.dto.chat.UpdateConversationRequest; import com.petshop.backend.entity.Conversation; import com.petshop.backend.entity.Customer; import com.petshop.backend.entity.Message; @@ -60,7 +60,7 @@ class ChatServiceTest { } @Test - void closeConversationMarksConversationClosed() { + void updateConversationMarksConversationClosed() { Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN); when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); @@ -68,7 +68,7 @@ class ChatServiceTest { when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)) .thenReturn(List.of(message("hello"))); - var response = chatService.closeConversation(99L, 10L, User.Role.CUSTOMER); + var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED")); assertEquals("CLOSED", response.getStatus()); assertEquals("hello", response.getLastMessage()); @@ -76,12 +76,85 @@ class ChatServiceTest { } @Test - void closeConversationRejectsOtherCustomer() { + void updateConversationRejectsOtherCustomer() { Conversation conversation = conversation(99L, 2L, null, Conversation.ConversationStatus.OPEN); when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); - assertThrows(AccessDeniedException.class, () -> chatService.closeConversation(99L, 10L, User.Role.CUSTOMER)); + assertThrows(AccessDeniedException.class, + () -> chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED"))); + } + + @Test + void updateConversationIsIdempotent() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of()); + + var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("CLOSED")); + + assertEquals("CLOSED", response.getStatus()); + } + + @Test + void staffCanCloseAssignedConversation() { + Conversation conversation = conversation(99L, 1L, 77L, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of()); + + var response = chatService.updateConversation(99L, 77L, User.Role.STAFF, new UpdateConversationRequest("CLOSED")); + + assertEquals("CLOSED", response.getStatus()); + } + + @Test + void staffCanCloseUnassignedConversation() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of()); + + var response = chatService.updateConversation(99L, 77L, User.Role.STAFF, new UpdateConversationRequest("CLOSED")); + + assertEquals("CLOSED", response.getStatus()); + } + + @Test + void adminCanCloseAnyConversation() { + Conversation conversation = conversation(99L, 2L, 88L, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of()); + + var response = chatService.updateConversation(99L, 1L, User.Role.ADMIN, new UpdateConversationRequest("CLOSED")); + + assertEquals("CLOSED", response.getStatus()); + } + + @Test + void updateConversationCanReopenClosedConversation() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.CLOSED); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); + when(conversationRepository.save(any(Conversation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(messageRepository.findByConversationIdOrderByTimestampAsc(99L)).thenReturn(List.of()); + + var response = chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("OPEN")); + + assertEquals("OPEN", response.getStatus()); + } + + @Test + void updateConversationRejectsInvalidStatus() { + Conversation conversation = conversation(99L, 1L, null, Conversation.ConversationStatus.OPEN); + when(conversationRepository.findById(99L)).thenReturn(Optional.of(conversation)); + when(customerRepository.findByUserId(10L)).thenReturn(Optional.of(customer)); + + assertThrows(IllegalArgumentException.class, + () -> chatService.updateConversation(99L, 10L, User.Role.CUSTOMER, new UpdateConversationRequest("INVALID"))); } @Test -- 2.49.1 From 72b423c8ad1f6f7c38c5a16aa61597ff5f9f1272 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 21:07:14 -0600 Subject: [PATCH 4/5] Add appointment tests --- .../service/AppointmentServiceTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) 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 fbc4ba4b..2a1e6eed 100644 --- a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java @@ -2,18 +2,26 @@ package com.petshop.backend.service; import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Customer; +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.CustomerRepository; +import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.ServiceRepository; import com.petshop.backend.repository.StoreRepository; +import com.petshop.backend.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import java.time.LocalDate; import java.time.LocalTime; @@ -21,8 +29,10 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -34,12 +44,18 @@ class AppointmentServiceTest { @Mock private CustomerRepository customerRepository; + @Mock + private PetRepository petRepository; + @Mock private ServiceRepository serviceRepository; @Mock private StoreRepository storeRepository; + @Mock + private UserRepository userRepository; + @InjectMocks private AppointmentService appointmentService; @@ -47,6 +63,7 @@ class AppointmentServiceTest { private StoreLocation store; private Service grooming; private Service nailTrim; + private Pet pet; private LocalDate date; @BeforeEach @@ -70,7 +87,17 @@ class AppointmentServiceTest { nailTrim.setServiceName("Nail Trim"); nailTrim.setServiceDuration(30); + pet = new Pet(); + pet.setPetId(1L); + pet.setPetName("Milo"); + date = LocalDate.now().plusDays(1); + + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } @Test @@ -97,6 +124,58 @@ class AppointmentServiceTest { assertFalse(slots.contains("10:00")); } + @Test + void cancelledAppointmentsDoNotBlockAvailability() { + when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); + when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + + List slots = appointmentService.checkAvailability(1L, 1L, date); + + assertTrue(slots.contains("10:00")); + } + + @Test + void updateAppointmentDoesNotConflictWithItself() { + Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store); + User user = new User(); + user.setId(10L); + user.setUsername("pat"); + 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")) + ) + ); + + when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing)); + 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(petRepository.findById(1L)).thenReturn(Optional.of(pet)); + when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing)); + when(appointmentRepository.save(any(Appointment.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + 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.setPetIds(List.of(1L)); + + var response = appointmentService.updateAppointment(1L, request); + + assertEquals(1L, response.getAppointmentId()); + assertEquals("Booked", response.getAppointmentStatus()); + } + private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) { Appointment appointment = new Appointment(); appointment.setAppointmentId(id); -- 2.49.1 From 5d490d7d0544fa404543a0fe43550654301dd354 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 21:14:53 -0600 Subject: [PATCH 5/5] Remove chat close wrapper --- .../main/java/com/petshop/backend/service/ChatService.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index d9165c9d..6ae0c4da 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -194,11 +194,6 @@ public class ChatService { return ConversationResponse.fromEntity(conversation, lastMessage); } - @Transactional - public ConversationResponse closeConversation(Long conversationId, Long userId, User.Role role) { - return updateConversation(conversationId, userId, role, new UpdateConversationRequest("CLOSED")); - } - public List getMessages(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); -- 2.49.1