From 36ac309442ecf39b463fc8eb8245a837f6fed7bd Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 29 Mar 2026 21:07:10 -0600 Subject: [PATCH] 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