From ab97a86977ead74ce10b43ee3e435ca793a225b2 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 19:02:12 -0600 Subject: [PATCH] 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; + } +}