diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2bac7..158e90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Live chat transfer (escalated-laravel `ChatController::transfer`): `POST /escalated/api/agent/chat/{sessionId}/transfer`, behind the agent check like the rest of the agent chat API, and, for the shared `Agent/TicketShow` transfer menu, `POST /escalated/agent/chat/{session}/transfer` from the agent's browser session (CSRF-checked, like End Chat), registered as `escalated.admin.chat.transfer` (Laravel's name). The body is `{agent_id, department_id}` (either or both); the shared `ChatActionBar`'s `{type: "agent"|"department", id}` is accepted too. With an agent, the session and ticket are reassigned to them. With only a department, the ticket moves there and the chat goes back to the queue (unassigned, waiting), then chat routing runs again for the new department. A missing or non-integer target, an unknown department, an ended chat, or an agent who is not active staff or cannot be assigned the ticket is a 422; an unknown session is a 404. New `ChatSessionService.transfer(...)`. +- `chat.transferred` is sent to outbound webhooks, runs workflows and is now in `EventNames.EMITTED`, so the webhook form and workflow builder offer it. The webhook data adds `from_agent_id`, `to_agent_id` and `to_department_id` to the chat fields (no widget token). `ChatEvent` has a `TRANSFERRED` type and `getTransfer()`. - End Chat on the agent ticket page works from the Inertia UI: `POST /escalated/agent/chat/{session}/end` (`escalated.admin.chat.end`, the name the shared TicketShow asks for, taking the chat session id) ends the session from the agent's signed-in browser session with the page's CSRF token. It reads diff --git a/src/main/java/dev/escalated/controllers/agent/AgentChatController.java b/src/main/java/dev/escalated/controllers/agent/AgentChatController.java index b95a613..6461c1c 100644 --- a/src/main/java/dev/escalated/controllers/agent/AgentChatController.java +++ b/src/main/java/dev/escalated/controllers/agent/AgentChatController.java @@ -74,6 +74,79 @@ public ResponseEntity end(@PathVariable Long sessionId) { return ResponseEntity.ok(chatSessionService.end(sessionId)); } + /** + * Transfer a chat to another agent and/or department (escalated-laravel + * {@code escalated.admin.chat.transfer}). The body is + * {@code {agent_id, department_id}} (either or both); the shared + * ChatActionBar's {@code {type: "agent"|"department", id}} is read too. + */ + @PostMapping("/{sessionId}/transfer") + public ResponseEntity> transfer(@PathVariable Long sessionId, + @RequestBody(required = false) Map body) { + return transfer(chatSessionService, sessionId, body); + } + + /** + * The transfer itself, shared with the agent UI route that TicketShow posts to from the + * browser session. An unknown session throws {@code EntityNotFoundException} (404). + */ + public static ResponseEntity> transfer(ChatSessionService chatSessionService, + Long sessionId, Map body) { + chatSessionService.findById(sessionId); + Map in = body == null ? Map.of() : body; + Object agent = in.get("agent_id"); + Object department = in.get("department_id"); + Object type = in.get("type"); + if ("agent".equals(type) && blank(agent)) { + agent = in.get("id"); + } else if ("department".equals(type) && blank(department)) { + department = in.get("id"); + } + + Long agentId; + Long departmentId; + try { + agentId = id(agent, "agent_id"); + departmentId = id(department, "department_id"); + } catch (IllegalArgumentException invalid) { + return unprocessable(invalid.getMessage()); + } + if (agentId == null && departmentId == null) { + return unprocessable("Provide an agent_id or department_id."); + } + + try { + ChatSession session = chatSessionService.transfer(sessionId, agentId, departmentId); + Map response = new java.util.LinkedHashMap<>(); + response.put("message", "Chat transferred."); + response.put("session", session); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException refused) { + return unprocessable(refused.getMessage()); + } + } + + private static boolean blank(Object value) { + return value == null || (value instanceof String text && text.isBlank()); + } + + private static Long id(Object value, String field) { + if (blank(value)) { + return null; + } + if (value instanceof Number number && number.doubleValue() == number.longValue() && number.longValue() > 0) { + return number.longValue(); + } + if (value instanceof String text && text.trim().matches("[1-9][0-9]{0,17}")) { + return Long.valueOf(text.trim()); + } + throw new IllegalArgumentException("The " + field + " must be an integer."); + } + + private static ResponseEntity> unprocessable(String message) { + return ResponseEntity.status(422).body(Map.of("message", message)); + } + @GetMapping("/{sessionId}") public ResponseEntity show(@PathVariable Long sessionId) { return ResponseEntity.ok(chatSessionService.findById(sessionId)); diff --git a/src/main/java/dev/escalated/events/ChatEvent.java b/src/main/java/dev/escalated/events/ChatEvent.java index 447f388..59a76aa 100644 --- a/src/main/java/dev/escalated/events/ChatEvent.java +++ b/src/main/java/dev/escalated/events/ChatEvent.java @@ -5,14 +5,16 @@ /** * A live-chat lifecycle event, announced to webhooks and workflows as - * {@code chat.started}, {@code chat.assigned}, {@code chat.message} or - * {@code chat.ended}. Carries ids only: never the visitor's widget token. + * {@code chat.started}, {@code chat.assigned}, {@code chat.transferred}, + * {@code chat.message} or {@code chat.ended}. Carries ids only: never the + * visitor's widget token. */ public class ChatEvent extends ApplicationEvent { public enum Type { STARTED(EventNames.CHAT_STARTED), ASSIGNED(EventNames.CHAT_ASSIGNED), + TRANSFERRED(EventNames.CHAT_TRANSFERRED), MESSAGE(EventNames.CHAT_MESSAGE), ENDED(EventNames.CHAT_ENDED); @@ -31,13 +33,28 @@ public String eventName() { private final Long sessionId; private final Long ticketId; private final Long agentId; + private final Transfer transfer; + + /** Who a chat was transferred from and to (escalated-laravel's ChatTransferred). */ + public record Transfer(Long fromAgentId, Long toAgentId, Long toDepartmentId) { + } public ChatEvent(Object source, Type type, ChatSession session) { + this(source, type, session, null); + } + + public ChatEvent(Object source, Type type, ChatSession session, Transfer transfer) { super(source); this.type = type; this.sessionId = session.getId(); this.ticketId = session.getTicketId(); this.agentId = session.getAgentId(); + this.transfer = transfer; + } + + /** The transfer details of a {@code chat.transferred} event; null otherwise. */ + public Transfer getTransfer() { + return transfer; } public Type getType() { diff --git a/src/main/java/dev/escalated/events/EventNames.java b/src/main/java/dev/escalated/events/EventNames.java index 5e531ed..df3b26a 100644 --- a/src/main/java/dev/escalated/events/EventNames.java +++ b/src/main/java/dev/escalated/events/EventNames.java @@ -48,14 +48,8 @@ public final class EventNames { TICKET_DEPARTMENT_CHANGED, TICKET_TAG_ADDED, TICKET_TAG_REMOVED, REPLY_CREATED, NOTE_CREATED, SLA_WARNING, SLA_BREACHED, CHAT_STARTED, CHAT_ASSIGNED, CHAT_TRANSFERRED, CHAT_MESSAGE, CHAT_ENDED); - /** - * The canonical names this package emits, and so offers as workflow - * triggers. {@code chat.transferred} is canonical but this port has no - * chat transfer yet. - */ - public static final List EMITTED = CANONICAL.stream() - .filter(name -> !CHAT_TRANSFERRED.equals(name)) - .toList(); + /** The canonical names this package emits, and so offers as workflow triggers. */ + public static final List EMITTED = CANONICAL; /** Legacy name to canonical name. Read, never emitted. */ public static final Map LEGACY; diff --git a/src/main/java/dev/escalated/events/WebhookEventListener.java b/src/main/java/dev/escalated/events/WebhookEventListener.java index 1a68cdf..8532b85 100644 --- a/src/main/java/dev/escalated/events/WebhookEventListener.java +++ b/src/main/java/dev/escalated/events/WebhookEventListener.java @@ -54,6 +54,11 @@ public void onChatEvent(ChatEvent event) { if (event.getAgentId() != null) { data.put("agent_id", event.getAgentId()); } + if (event.getTransfer() != null) { + data.put("from_agent_id", event.getTransfer().fromAgentId()); + data.put("to_agent_id", event.getTransfer().toAgentId()); + data.put("to_department_id", event.getTransfer().toDepartmentId()); + } webhookService.dispatchEvent(event.getType().eventName(), event.getTicketId(), data); } diff --git a/src/main/java/dev/escalated/services/ChatSessionService.java b/src/main/java/dev/escalated/services/ChatSessionService.java index 5b5909b..72e4a0e 100644 --- a/src/main/java/dev/escalated/services/ChatSessionService.java +++ b/src/main/java/dev/escalated/services/ChatSessionService.java @@ -57,6 +57,15 @@ public ChatSessionService(ChatSessionRepository chatSessionRepository, this.messagingTemplate = messagingTemplate; } + @org.springframework.beans.factory.annotation.Autowired + private dev.escalated.repositories.AgentProfileRepository agentProfiles; + + @org.springframework.beans.factory.annotation.Autowired + private dev.escalated.repositories.DepartmentRepository departments; + + @org.springframework.beans.factory.annotation.Autowired + private dev.escalated.repositories.TicketRepository tickets; + private void announce(ChatEvent.Type type, ChatSession session) { if (eventPublisher != null && session != null) { eventPublisher.publishEvent(new ChatEvent(this, type, session)); @@ -214,6 +223,97 @@ public ChatSession accept(Long sessionId, Long agentId) { return session; } + /** + * Hand a chat to another agent and/or department, as escalated-laravel's + * ChatSessionService::transferChat. With an agent, the session and its + * ticket are reassigned to that agent. With a department, the ticket + * moves there; without an agent too, the chat goes back to the queue + * (unassigned, waiting) and routing picks an agent for its new + * department. Announces {@code chat.transferred} with the previous agent, + * the new agent and the new department. + * + * @throws EntityNotFoundException for an unknown session + * @throws IllegalArgumentException when neither target is given, the chat + * has ended, the department does not exist, or the agent is not + * active staff who can be assigned the ticket + */ + @Transactional(transactionManager = EscalatedTransactionManagers.ESCALATED) + public ChatSession transfer(Long sessionId, Long toAgentId, Long toDepartmentId) { + ChatSession session = findById(sessionId); + + if ("ended".equals(session.getStatus())) { + throw new IllegalArgumentException("Chat session has already ended."); + } + if (toAgentId == null && toDepartmentId == null) { + throw new IllegalArgumentException("Provide an agent_id or department_id."); + } + + dev.escalated.models.Department department = null; + if (toDepartmentId != null) { + department = departments.findById(toDepartmentId) + .orElseThrow(() -> new IllegalArgumentException("The selected department_id is invalid.")); + } + dev.escalated.models.AgentProfile agent = null; + if (toAgentId != null) { + agent = agentProfiles.findById(toAgentId) + .filter(candidate -> candidate.isActive() && (candidate.isAgent() || candidate.isAdmin())) + .orElseThrow(() -> new IllegalArgumentException("Agent cannot access this ticket.")); + } + + Ticket ticket = ticketService.findById(session.getTicketId()); + Long fromAgentId = session.getAgentId(); + final dev.escalated.models.Department targetDepartment = department; + final dev.escalated.models.AgentProfile targetAgent = agent; + java.util.function.Supplier move = () -> { + if (targetDepartment != null) { + ticket.setDepartment(targetDepartment); + } + // Checked against the ticket in its new department. + if (targetAgent != null && access != null && !access.canAssign(ticket, targetAgent)) { + throw new IllegalArgumentException("Agent cannot access this ticket."); + } + ticket.setAssignedAgent(targetAgent); + return tickets.save(ticket); + }; + // A refusal rolls the department change back with the transaction. + if (access == null) { + move.get(); + } else { + access.withoutRestriction(move); + } + + if (toDepartmentId != null) { + session.setDepartmentId(toDepartmentId); + } + if (toAgentId != null) { + session.setAgentId(toAgentId); + } else { + session.setAgentId(null); + session.setStatus("waiting"); + session.setAcceptedAt(null); + } + session = chatSessionRepository.save(session); + + broadcast("/topic/chat/" + sessionId, session); + if (eventPublisher != null) { + eventPublisher.publishEvent(new ChatEvent(this, ChatEvent.Type.TRANSFERRED, session, + new ChatEvent.Transfer(fromAgentId, toAgentId, toDepartmentId))); + } + + if (toAgentId == null) { + ChatRoutingService.RouteResult route = chatRoutingService.resolve(toDepartmentId); + boolean eligible = route.agentId() != null + && agentProfiles.findById(route.agentId()) + .filter(candidate -> access == null || access.canAssign(ticket, candidate)) + .isPresent(); + if (eligible) { + session = accept(sessionId, route.agentId()); + } + } + + return session; + } + /** * Send a message within a chat session. */ diff --git a/src/main/java/dev/escalated/ui/UiRoutes.java b/src/main/java/dev/escalated/ui/UiRoutes.java index 4611cd8..c310d84 100644 --- a/src/main/java/dev/escalated/ui/UiRoutes.java +++ b/src/main/java/dev/escalated/ui/UiRoutes.java @@ -44,6 +44,8 @@ public UiRoutes() { register("escalated.agent.tickets.custom-action", "/agent/tickets/{ticket}/actions/{action}"); // End Chat on the agent ticket page; Laravel serves it at /admin/chat/{session}/end under this name. register("escalated.admin.chat.end", "/agent/chat/{session}/end"); + // Live chat transfer from TicketShow's action bar, from the agent's browser session like End Chat. + register("escalated.admin.chat.transfer", "/agent/chat/{session}/transfer"); // Customer register("escalated.customer.tickets.index", ""); register("escalated.customer.tickets.create", "/create"); diff --git a/src/main/java/dev/escalated/ui/controllers/AgentUiController.java b/src/main/java/dev/escalated/ui/controllers/AgentUiController.java index 2b82f35..2dcae31 100644 --- a/src/main/java/dev/escalated/ui/controllers/AgentUiController.java +++ b/src/main/java/dev/escalated/ui/controllers/AgentUiController.java @@ -95,6 +95,18 @@ public ResponseEntity> endChat(@PathVariable("session") Long return ResponseEntity.ok(Map.of("message", "Chat ended.")); } + /** + * Transfer from TicketShow's action bar ({@code escalated.admin.chat.transfer}): the chat + * session named by id, from the agent's browser session, as the API's + * {@code POST /escalated/api/agent/chat/{id}/transfer} does. 200, 422 or 404. + */ + @PostMapping("/chat/{session}/transfer") + public ResponseEntity> transferChat( + @PathVariable("session") Long sessionId, + @org.springframework.web.bind.annotation.RequestBody(required = false) Map body) { + return dev.escalated.controllers.agent.AgentChatController.transfer(chats, sessionId, body); + } + @PostMapping("/tickets/{ticket}/actions/{action}") public ResponseEntity customAction(@PathVariable("ticket") String reference, @PathVariable String action, HttpServletRequest request, Authentication authentication) { diff --git a/src/test/java/dev/escalated/controllers/ChatTransferTest.java b/src/test/java/dev/escalated/controllers/ChatTransferTest.java new file mode 100644 index 0000000..2ac14ad --- /dev/null +++ b/src/test/java/dev/escalated/controllers/ChatTransferTest.java @@ -0,0 +1,312 @@ +package dev.escalated.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.escalated.config.EscalatedTransactionManagers; +import dev.escalated.events.EventNames; +import dev.escalated.models.AgentProfile; +import dev.escalated.models.ChatRoutingRule; +import dev.escalated.models.ChatSession; +import dev.escalated.models.Department; +import dev.escalated.models.Ticket; +import dev.escalated.models.Workflow; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.repositories.ChatRoutingRuleRepository; +import dev.escalated.repositories.DepartmentRepository; +import dev.escalated.repositories.TicketRepository; +import dev.escalated.repositories.WorkflowLogRepository; +import dev.escalated.repositories.WorkflowRepository; +import dev.escalated.services.ChatSessionService; +import dev.escalated.services.WebhookService; +import dev.escalated.services.WorkflowEngine; +import dev.escalated.ui.UiRoutes; +import hostapp.HostApplication; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Live chat transfer, as escalated-laravel's ChatController::transfer and + * ChatSessionService::transferChat: {@code escalated.admin.chat.transfer} + * takes the chat session id (the shared Agent/TicketShow page passes + * {@code chat_session_id}) and {@code {agent_id, department_id}}; the shared + * ChatActionBar's {@code {type, id}} is read too. It emits + * {@code chat.transferred} to webhooks and workflows. + */ +@SpringBootTest(classes = HostApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = "spring.mail.host=localhost") +class ChatTransferTest { + + private static final long WAIT_MS = 10_000; + + @Autowired private MockMvc mockMvc; + @Autowired private ChatSessionService chats; + @Autowired private AgentProfileRepository agents; + @Autowired private DepartmentRepository departments; + @Autowired private ChatRoutingRuleRepository routingRules; + @Autowired private TicketRepository tickets; + @Autowired private WorkflowRepository workflows; + @Autowired private WorkflowLogRepository workflowLogs; + + @Autowired + @Qualifier(EscalatedTransactionManagers.ESCALATED) + private PlatformTransactionManager transactionManager; + + @MockitoBean private WebhookService webhookService; + + private String unique; + private AgentProfile from; + private AgentProfile to; + private final List createdRules = new ArrayList<>(); + private final List createdWorkflows = new ArrayList<>(); + + @BeforeEach + void seed() { + unique = UUID.randomUUID().toString().substring(0, 8); + from = staff("From " + unique, true, false); + to = staff("To " + unique, true, false); + } + + @AfterEach + void cleanUp() { + // Routing rules and workflows are global: leaving them would route or + // tag other tests' chats. + await().pollDelay(Duration.ofMillis(300)).atMost(Duration.ofSeconds(10)).until(() -> true); + createdRules.forEach(routingRules::deleteById); + inTransaction(() -> { + workflowLogs.findAll().stream() + .filter(row -> row.getWorkflow() != null && createdWorkflows.contains(row.getWorkflow().getId())) + .forEach(workflowLogs::delete); + createdWorkflows.forEach(workflows::deleteById); + return null; + }); + } + + @Test + void theRouteHasLaravelsName() { + assertThat(new UiRoutes().all()) + .containsEntry("escalated.admin.chat.transfer", "/escalated/agent/chat/{session}/transfer"); + } + + @Test + void transferToAnAgentReassignsTheChatAndTicket() throws Exception { + ChatSession session = activeChat(); + String marker = "transferred-" + unique; + workflow(marker); + + transfer(session.getId(), "{\"agent_id\":" + to.getId() + "}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Chat transferred.")) + .andExpect(jsonPath("$.session.agentId").value(to.getId())); + + ChatSession fresh = chats.findById(session.getId()); + assertThat(fresh.getAgentId()).isEqualTo(to.getId()); + assertThat(fresh.getStatus()).isEqualTo("active"); + assertThat(assignedAgentId(session.getTicketId())).isEqualTo(to.getId()); + + verify(webhookService, timeout(WAIT_MS)).dispatchEvent(eq("chat.transferred"), eq(session.getTicketId()), + argThat(data -> session.getId().equals(data.get("chat_session_id")) + && from.getId().equals(data.get("from_agent_id")) + && to.getId().equals(data.get("to_agent_id")) + && data.containsKey("to_department_id") && data.get("to_department_id") == null + && !data.containsKey("token"))); + await().atMost(Duration.ofSeconds(10)).until(() -> inTransaction(() -> workflowLogs.findAll().stream() + .anyMatch(row -> row.getWorkflow() != null + && createdWorkflows.contains(row.getWorkflow().getId()) + && row.getTicket() != null && Objects.equals(row.getTicket().getId(), session.getTicketId())))); + } + + @Test + void departmentOnlyTransferRequeuesAndReroutes() throws Exception { + Department billing = department(); + AgentProfile routed = staff("Routed " + unique, true, false); + ChatSession session = activeChat(); + ChatRoutingRule rule = new ChatRoutingRule(); + rule.setName("Billing " + unique); + rule.setDepartmentId(billing.getId()); + rule.setAgentId(routed.getId()); + rule.setActive(true); + createdRules.add(routingRules.save(rule).getId()); + + transfer(session.getId(), "{\"department_id\":" + billing.getId() + "}").andExpect(status().isOk()); + + ChatSession fresh = chats.findById(session.getId()); + assertThat(fresh.getDepartmentId()).isEqualTo(billing.getId()); + assertThat(fresh.getAgentId()).isEqualTo(routed.getId()); + assertThat(fresh.getStatus()).isEqualTo("active"); + assertThat(departmentId(session.getTicketId())).isEqualTo(billing.getId()); + assertThat(assignedAgentId(session.getTicketId())).isEqualTo(routed.getId()); + + verify(webhookService, timeout(WAIT_MS)).dispatchEvent(eq("chat.transferred"), eq(session.getTicketId()), + argThat(data -> from.getId().equals(data.get("from_agent_id")) + && data.get("to_agent_id") == null + && billing.getId().equals(data.get("to_department_id")))); + } + + @Test + void departmentOnlyTransferWithNoAgentLeavesTheChatWaiting() throws Exception { + Department billing = department(); + ChatSession session = activeChat(); + + transfer(session.getId(), "{\"department_id\":\"" + billing.getId() + "\"}").andExpect(status().isOk()); + + ChatSession fresh = chats.findById(session.getId()); + assertThat(fresh.getAgentId()).isNull(); + assertThat(fresh.getStatus()).isEqualTo("waiting"); + assertThat(fresh.getAcceptedAt()).isNull(); + assertThat(assignedAgentId(session.getTicketId())).isNull(); + assertThat(departmentId(session.getTicketId())).isEqualTo(billing.getId()); + } + + @Test + void agentAndDepartmentTogether() throws Exception { + Department billing = department(); + ChatSession session = activeChat(); + + transfer(session.getId(), "{\"agent_id\":\"" + to.getId() + "\",\"department_id\":" + billing.getId() + "}") + .andExpect(status().isOk()); + + ChatSession fresh = chats.findById(session.getId()); + assertThat(fresh.getAgentId()).isEqualTo(to.getId()); + assertThat(fresh.getStatus()).isEqualTo("active"); + assertThat(departmentId(session.getTicketId())).isEqualTo(billing.getId()); + } + + @Test + void theChatActionBarBodyIsAccepted() throws Exception { + Department billing = department(); + ChatSession session = activeChat(); + + transfer(session.getId(), "{\"type\":\"agent\",\"id\":" + to.getId() + "}").andExpect(status().isOk()); + assertThat(chats.findById(session.getId()).getAgentId()).isEqualTo(to.getId()); + + transfer(session.getId(), "{\"type\":\"department\",\"id\":" + billing.getId() + "}") + .andExpect(status().isOk()); + assertThat(departmentId(session.getTicketId())).isEqualTo(billing.getId()); + } + + @Test + void refusals() throws Exception { + ChatSession session = activeChat(); + AgentProfile notStaff = staff("Customer " + unique, false, false); + AgentProfile inactive = staff("Gone " + unique, true, false); + inactive.setActive(false); + agents.save(inactive); + + mockMvc.perform(post("/escalated/api/agent/chat/{id}/transfer", session.getId()) + .contentType(MediaType.APPLICATION_JSON).content("{\"agent_id\":" + to.getId() + "}")) + .andExpect(result -> assertThat(result.getResponse().getStatus()).isIn(401, 403)); + + transfer(session.getId(), "{}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"agent_id\":null,\"department_id\":\"\"}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"department_id\":99999999}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"department_id\":\"billing\"}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"agent_id\":99999999}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"agent_id\":" + notStaff.getId() + "}").andExpect(status().isUnprocessableContent()); + transfer(session.getId(), "{\"agent_id\":" + inactive.getId() + "}").andExpect(status().isUnprocessableContent()); + transfer(99999999L, "{\"agent_id\":" + to.getId() + "}").andExpect(status().isNotFound()); + + chats.end(session.getId()); + transfer(session.getId(), "{\"agent_id\":" + to.getId() + "}").andExpect(status().isUnprocessableContent()); + + assertThat(chats.findById(session.getId()).getAgentId()).isEqualTo(from.getId()); + verify(webhookService, after(1000).never()).dispatchEvent(eq("chat.transferred"), anyLong(), anyMap()); + } + + @Test + void chatTransferredIsOfferedToWebhooksAndWorkflows() { + assertThat(EventNames.EMITTED).contains("chat.transferred"); + assertThat(WorkflowEngine.TRIGGER_EVENTS).contains("chat.transferred"); + } + + private ResultActions transfer(Long sessionId, String body) throws Exception { + return mockMvc.perform(post("/escalated/api/agent/chat/{id}/transfer", sessionId) + .with(user(from.getEmail())) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } + + private ChatSession activeChat() throws Exception { + Long sessionId = chats.startForWidget("Visitor", "visitor-" + unique + "@example.com", "Hi", null, null) + .session().getId(); + if ("waiting".equals(chats.findById(sessionId).getStatus())) { + chats.accept(sessionId, from.getId()); + } + return chats.findById(sessionId); + } + + private Department department() { + Department department = new Department(); + department.setName("Billing " + unique + " " + UUID.randomUUID().toString().substring(0, 4)); + return departments.save(department); + } + + private void workflow(String tagName) { + Workflow workflow = new Workflow(); + workflow.setName("On transfer " + unique); + workflow.setTriggerEvent("chat.transferred"); + workflow.setActions("[{\"type\":\"add_tag\",\"value\":\"" + tagName + "\"}]"); + workflow.setActive(true); + createdWorkflows.add(workflows.save(workflow).getId()); + } + + private AgentProfile staff(String name, boolean agent, boolean admin) { + AgentProfile profile = new AgentProfile(); + profile.setName(name); + profile.setEmail("staff-" + UUID.randomUUID() + "@example.com"); + profile.setAgent(agent); + profile.setAdmin(admin); + return agents.save(profile); + } + + private Long assignedAgentId(Long ticketId) { + return inTransaction(() -> { + Ticket ticket = tickets.findById(ticketId).orElseThrow(); + return ticket.getAssignedAgent() == null ? null : ticket.getAssignedAgent().getId(); + }); + } + + private Long departmentId(Long ticketId) { + return inTransaction(() -> { + Ticket ticket = tickets.findById(ticketId).orElseThrow(); + return ticket.getDepartment() == null ? null : ticket.getDepartment().getId(); + }); + } + + private T inTransaction(Supplier work) { + return new TransactionTemplate(transactionManager).execute(status -> work.get()); + } +} diff --git a/src/test/java/dev/escalated/ui/AgentChatTransferUiTest.java b/src/test/java/dev/escalated/ui/AgentChatTransferUiTest.java new file mode 100644 index 0000000..81063cc --- /dev/null +++ b/src/test/java/dev/escalated/ui/AgentChatTransferUiTest.java @@ -0,0 +1,133 @@ +package dev.escalated.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import dev.escalated.models.AgentProfile; +import dev.escalated.repositories.AgentProfileRepository; +import dev.escalated.services.ChatSessionService; +import hostapp.HostApplication; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +/** + * Transfer from the agent ticket page: {@code route('escalated.admin.chat.transfer', chat_session_id)} + * POSTed with {@code {agent_id}} or {@code {department_id}}, the csrf-token meta tag and the agent's + * browser session, as the shared frontend's ChatActionBar does. + */ +@SpringBootTest(classes = HostApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = { + "spring.mail.host=localhost", + "escalated.ui.enabled=true", + "escalated.ui.version=build-1", +}) +class AgentChatTransferUiTest { + + @Autowired private MockMvc mvc; + @Autowired private AgentProfileRepository agents; + @Autowired private ChatSessionService chats; + @Autowired private UiRoutes routes; + + private AgentProfile from; + private AgentProfile to; + private Long sessionId; + + @BeforeEach + void seed() { + String unique = UUID.randomUUID().toString().substring(0, 8); + from = agent("From " + unique); + to = agent("To " + unique); + sessionId = chats.startForWidget("Visitor", "visitor-" + unique + "@example.com", "Hello?", null, null) + .session().getId(); + if ("waiting".equals(chats.findById(sessionId).getStatus())) { + chats.accept(sessionId, from.getId()); + } + } + + @Test + void theRouteNameResolvesToTheUiEndpoint() { + assertThat(routes.url("escalated.admin.chat.transfer", sessionId)) + .isEqualTo("/escalated/agent/chat/" + sessionId + "/transfer"); + assertThat(routes.script()).contains("escalated.admin.chat.transfer"); + } + + @Test + void anAgentsBrowserSessionTransfersTheChatThroughTheNamedRoute() throws Exception { + transfer(sessionId, "{\"agent_id\":" + to.getId() + "}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Chat transferred.")); + + assertThat(chats.findById(sessionId).getAgentId()).isEqualTo(to.getId()); + } + + @Test + void refusalsAre422And404() throws Exception { + transfer(sessionId, "{}").andExpect(status().isUnprocessableEntity()); + transfer(Long.MAX_VALUE, "{\"agent_id\":" + to.getId() + "}").andExpect(status().isNotFound()); + assertThat(chats.findById(sessionId).getAgentId()).isEqualTo(from.getId()); + } + + @Test + void withoutTheCsrfTokenOrAsANonAgentItIsRefused() throws Exception { + mvc.perform(post(routes.url("escalated.admin.chat.transfer", sessionId)).session(agentSession()) + .contentType(MediaType.APPLICATION_JSON).content("{\"agent_id\":" + to.getId() + "}")) + .andExpect(status().isForbidden()); + mvc.perform(withCsrf(post(routes.url("escalated.admin.chat.transfer", sessionId))) + .with(user("customer-" + UUID.randomUUID() + "@example.com")) + .contentType(MediaType.APPLICATION_JSON).content("{\"agent_id\":" + to.getId() + "}")) + .andExpect(status().isForbidden()); + assertThat(chats.findById(sessionId).getAgentId()).isEqualTo(from.getId()); + } + + private ResultActions transfer(Long id, String body) throws Exception { + return mvc.perform(withCsrf(post(routes.url("escalated.admin.chat.transfer", id))).session(agentSession()) + .accept(MediaType.APPLICATION_JSON) + .header("X-Requested-With", "XMLHttpRequest") + .contentType(MediaType.APPLICATION_JSON).content(body)); + } + + private AgentProfile agent(String name) { + AgentProfile agent = new AgentProfile(); + agent.setName(name); + agent.setEmail("chat-agent-" + UUID.randomUUID() + "@example.com"); + agent.setAgent(true); + return agents.save(agent); + } + + /** The csrf-token meta tag's value sent back as X-CSRF-TOKEN, with its cookie. */ + private static MockHttpServletRequestBuilder withCsrf(MockHttpServletRequestBuilder request) { + String token = UUID.randomUUID().toString(); + return request.cookie(new jakarta.servlet.http.Cookie("XSRF-TOKEN", token)).header("X-CSRF-TOKEN", token); + } + + /** A signed-in browser session: the security context stored in the HTTP session. */ + private MockHttpSession agentSession() { + MockHttpSession session = new MockHttpSession(); + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + from.getEmail(), null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + new SecurityContextImpl(authentication)); + return session; + } +}