diff --git a/CHANGELOG.md b/CHANGELOG.md index be40699..afe4123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Live chat transfer (escalated-laravel `escalated.admin.chat.transfer`): `POST /wp-json/escalated/v1/chat/{session_id}/transfer`, with an API token like the other agent chat endpoints. 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 staff or cannot see the ticket is a 422; an unknown session is a 404. New `ChatSessionService::transfer()` and `escalated_chat_transferred` action (`$session, $from_agent_id, $to_agent_id, $to_department_id`). +- `chat.transferred` runs workflows (new trigger, "Chat transferred") and is sent to webhooks subscribed to it; the webhook form offers it. Its payload is the ticket plus `chat {session_id, ticket_id, status, agent_id, department_id, from_agent_id, to_agent_id, to_department_id}`, as escalated-laravel's. Existing webhooks are not subscribed to it automatically. + ## [1.6.4] - 2026-10-22 ### Upgrading diff --git a/docs/webhooks.md b/docs/webhooks.md index 08fc1fa..63c625f 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -29,7 +29,7 @@ The `escalated_webhook_args` filter can change the request arguments. `escalated The canonical names from the shared event vocabulary: -`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`. +`ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.resolved`, `ticket.closed`, `ticket.reopened`, `ticket.assigned`, `ticket.unassigned`, `ticket.escalated`, `ticket.priority_changed`, `ticket.department_changed`, `reply.created`, `note.created`, `sla.breached`, `sla.warning`, `ticket.tag_added`, `ticket.tag_removed`, `chat.transferred`. A webhook saved with an older name (`ticket.tagged`, `internal_note.added`, ...) receives the event under its current name. diff --git a/includes/Api/class-chat-controller.php b/includes/Api/class-chat-controller.php index 66fb960..0f134f4 100644 --- a/includes/Api/class-chat-controller.php +++ b/includes/Api/class-chat-controller.php @@ -131,6 +131,27 @@ public function register_routes(): void ] ); + // Transfer a chat to another agent and/or department + // (escalated-laravel escalated.admin.chat.transfer). + register_rest_route( + $this->namespace, + '/'.$this->rest_base.'/(?P\d+)/transfer', + [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'transfer_session'], + 'permission_callback' => [$this, 'token_permissions_check'], + 'args' => [ + 'session_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ], + ] + ); + // Get a specific session. register_rest_route( $this->namespace, @@ -212,6 +233,60 @@ public function end_session(WP_REST_Request $request) } } + /** + * POST /chat/{session_id}/transfer. The body is {agent_id, department_id} + * (either or both), as escalated-laravel's ChatController::transfer; the + * shared ChatActionBar's {type: "agent"|"department", id} is read too. + */ + public function transfer_session(WP_REST_Request $request) + { + $session_id = (int) $request->get_param('session_id'); + + if (! \Escalated\Models\ChatSession::find($session_id)) { + return $this->error('escalated_not_found', __('Chat session not found.', 'escalated'), 404); + } + + $agent_id = $request->get_param('agent_id'); + $department_id = $request->get_param('department_id'); + $type = $request->get_param('type'); + if ($type === 'agent' && self::blank($agent_id)) { + $agent_id = $request->get_param('id'); + } elseif ($type === 'department' && self::blank($department_id)) { + $department_id = $request->get_param('id'); + } + + foreach (['agent_id' => $agent_id, 'department_id' => $department_id] as $field => $value) { + if (! self::blank($value) && (is_bool($value) || filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false)) { + /* translators: %s: field name */ + return $this->error('escalated_validation', sprintf(__('The %s must be an integer.', 'escalated'), $field), 422); + } + } + $agent_id = self::blank($agent_id) ? null : (int) $agent_id; + $department_id = self::blank($department_id) ? null : (int) $department_id; + + if ($agent_id === null && $department_id === null) { + return $this->error('escalated_validation', __('Provide an agent_id or department_id.', 'escalated'), 422); + } + + try { + $session = (new ChatSessionService)->transfer($session_id, $agent_id, $department_id); + } catch (\InvalidArgumentException $e) { + return $this->error('escalated_chat_transfer_refused', $e->getMessage(), 422); + } catch (\Throwable $e) { + return $this->error('escalated_chat_error', $e->getMessage(), 500); + } + + return $this->success([ + 'message' => __('Chat transferred.', 'escalated'), + 'session' => $session, + ]); + } + + private static function blank($value): bool + { + return $value === null || (is_string($value) && trim($value) === ''); + } + public function get_session(WP_REST_Request $request) { $session_id = (int) $request->get_param('session_id'); diff --git a/includes/Services/ChatSessionService.php b/includes/Services/ChatSessionService.php index 6102a94..6a27862 100644 --- a/includes/Services/ChatSessionService.php +++ b/includes/Services/ChatSessionService.php @@ -103,6 +103,86 @@ public function accept(int $session_id, int $agent_id): object 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. Fires escalated_chat_transferred (chat.transferred) with + * the previous agent, the new agent and the new department. + * + * @throws \InvalidArgumentException when neither target is given, the chat + * has ended, the department does not + * exist, or the agent cannot take it + */ + public function transfer(int $session_id, ?int $to_agent_id = null, ?int $to_department_id = null): object + { + $session = ChatSession::find($session_id); + + if (! $session) { + throw new \InvalidArgumentException('Chat session not found.'); + } + + if ($session->status === 'ended') { + throw new \InvalidArgumentException('Chat session has already ended.'); + } + + if (! $to_agent_id && ! $to_department_id) { + throw new \InvalidArgumentException('Provide an agent_id or department_id.'); + } + + if ($to_department_id && ! \Escalated\Models\Department::find($to_department_id)) { + throw new \InvalidArgumentException('The selected department_id is invalid.'); + } + + $ticket_id = (int) $session->ticket_id; + $from_agent_id = $session->agent_id ? (int) $session->agent_id : null; + + return TicketAccess::without_restriction(function () use ($session_id, $ticket_id, $from_agent_id, $to_agent_id, $to_department_id) { + // Checked against the ticket as it will be (in its new department). + $prospective = (array) Ticket::find($ticket_id); + if ($to_department_id) { + $prospective['department_id'] = $to_department_id; + } + if ($to_agent_id && (! TicketAccess::is_staff($to_agent_id) || ! TicketAccess::allows($prospective, $to_agent_id))) { + throw new \InvalidArgumentException('Agent cannot access this ticket.'); + } + + $ticket_updates = ['assigned_to' => $to_agent_id ?: null]; + if ($to_department_id) { + $ticket_updates['department_id'] = $to_department_id; + } + Ticket::update($ticket_id, $ticket_updates); + + $session_updates = []; + if ($to_department_id) { + $session_updates['department_id'] = $to_department_id; + } + if ($to_agent_id) { + $session_updates['agent_id'] = $to_agent_id; + } else { + $session_updates['agent_id'] = null; + $session_updates['status'] = 'waiting'; + $session_updates['accepted_at'] = null; + } + ChatSession::update($session_id, $session_updates); + + $session = ChatSession::find($session_id); + + do_action('escalated_chat_transferred', $session, $from_agent_id, $to_agent_id, $to_department_id); + + if (! $to_agent_id) { + $route = $this->routing->resolve($to_department_id); + if (! empty($route['agent_id']) && TicketAccess::can_receive($ticket_id, (int) $route['agent_id'])) { + $session = $this->accept($session_id, (int) $route['agent_id']); + } + } + + return $session; + }); + } + /** * Send a message within a chat session. Stored as a reply on the ticket. */ diff --git a/includes/Services/EventVocabulary.php b/includes/Services/EventVocabulary.php index 7402f9f..f49d0fa 100644 --- a/includes/Services/EventVocabulary.php +++ b/includes/Services/EventVocabulary.php @@ -49,11 +49,11 @@ final class EventVocabulary 'sla.warning', 'ticket.tag_added', 'ticket.tag_removed', + 'chat.transferred', ]; /** - * The events WorkflowListener runs workflows on. Live chat has no - * transfer, so chat.transferred is not fired. reply.agent_reply is a + * The events WorkflowListener runs workflows on. reply.agent_reply is a * WordPress extension: reply.created limited to staff replies. */ public const WORKFLOW_TRIGGERS = [ @@ -77,6 +77,7 @@ final class EventVocabulary 'sla.breached', 'chat.started', 'chat.assigned', + 'chat.transferred', 'chat.message', 'chat.ended', ]; diff --git a/includes/Services/NotificationService.php b/includes/Services/NotificationService.php index faf5da1..17f8674 100644 --- a/includes/Services/NotificationService.php +++ b/includes/Services/NotificationService.php @@ -35,6 +35,7 @@ public function register(): void add_action('escalated_internal_note_added', [$this, 'on_note_created'], 100, 2); add_action('escalated_tag_added', [$this, 'on_tag_added'], 100, 2); add_action('escalated_tag_removed', [$this, 'on_tag_removed'], 100, 2); + add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 100, 4); } public function on_ticket_resolved($ticket, $causer_id = null): void @@ -87,6 +88,29 @@ public function on_note_created($note, $ticket = null): void ]); } + /** + * chat.transferred, with escalated-laravel's DispatchWebhook chat fields. + * The visitor's email and chat token stay out. + */ + public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void + { + if (! is_object($session) || empty($session->ticket_id)) { + return; + } + $this->dispatch_ticket_event('chat.transferred', Ticket::find((int) $session->ticket_id), [ + 'chat' => [ + 'session_id' => (int) $session->id, + 'ticket_id' => (int) $session->ticket_id, + 'status' => $session->status ?? null, + 'agent_id' => self::nullable_int($session->agent_id ?? null), + 'department_id' => self::nullable_int($session->department_id ?? null), + 'from_agent_id' => self::nullable_int($from_agent_id), + 'to_agent_id' => self::nullable_int($to_agent_id), + 'to_department_id' => self::nullable_int($to_department_id), + ], + ]); + } + public function on_tag_added($ticket_id, $tag_id = null): void { $this->dispatch_tag_event('ticket.tag_added', $ticket_id, $tag_id); diff --git a/includes/Services/WorkflowAdmin.php b/includes/Services/WorkflowAdmin.php index fcd92e8..f7df264 100644 --- a/includes/Services/WorkflowAdmin.php +++ b/includes/Services/WorkflowAdmin.php @@ -218,6 +218,7 @@ public static function trigger_label(string $event): string 'sla.breached' => __('SLA breached', 'escalated'), 'chat.started' => __('Chat started', 'escalated'), 'chat.assigned' => __('Chat assigned', 'escalated'), + 'chat.transferred' => __('Chat transferred', 'escalated'), 'chat.message' => __('Chat message', 'escalated'), 'chat.ended' => __('Chat ended', 'escalated'), ]; diff --git a/includes/Services/WorkflowListener.php b/includes/Services/WorkflowListener.php index 9b75617..4378790 100644 --- a/includes/Services/WorkflowListener.php +++ b/includes/Services/WorkflowListener.php @@ -51,6 +51,7 @@ public function register(): void add_action('escalated_sla_breached', [$this, 'on_sla_breached'], 50, 2); add_action('escalated_chat_started', [$this, 'on_chat_started'], 50, 1); add_action('escalated_chat_accepted', [$this, 'on_chat_assigned'], 50, 2); + add_action('escalated_chat_transferred', [$this, 'on_chat_transferred'], 50, 4); add_action('escalated_chat_message', [$this, 'on_chat_message'], 50, 2); add_action('escalated_chat_ended', [$this, 'on_chat_ended'], 50, 2); } @@ -123,6 +124,11 @@ public function on_chat_assigned($session, $agent_id = null): void $this->run_for_chat('chat.assigned', $session); } + public function on_chat_transferred($session, $from_agent_id = null, $to_agent_id = null, $to_department_id = null): void + { + $this->run_for_chat('chat.transferred', $session); + } + public function on_chat_message($session, $reply = null): void { $this->run_for_chat('chat.message', $session); diff --git a/tests/Test_Chat_Transfer.php b/tests/Test_Chat_Transfer.php new file mode 100644 index 0000000..9ca4191 --- /dev/null +++ b/tests/Test_Chat_Transfer.php @@ -0,0 +1,344 @@ + */ + private array $http = []; + + public function set_up(): void + { + parent::set_up(); + + \Escalated\Activator::activate(); + + $this->admin_id = $this->factory->user->create(['role' => 'administrator']); + $this->from_agent = $this->factory->user->create(['role' => 'escalated_agent']); + $this->to_agent = $this->factory->user->create(['role' => 'escalated_agent']); + + $this->token = wp_generate_password(64, false); + global $wpdb; + $wpdb->insert(\Escalated\Escalated::table('api_tokens'), [ + 'user_id' => $this->admin_id, + 'name' => 'Test Token', + 'token' => hash('sha256', $this->token), + 'abilities' => wp_json_encode(['*']), + 'created_at' => current_time('mysql'), + ]); + + Setting::set('webhook_url', self::WEBHOOK_URL); + Setting::set('webhook_secret', 'webhook-secret'); + $this->http = []; + add_filter('pre_http_request', [$this, 'record_http'], 10, 3); + add_filter('escalated_outbound_url_resolve', [$this, 'resolve_host'], 10, 2); + + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server; + do_action('rest_api_init'); + } + + public function tear_down(): void + { + global $wp_rest_server; + $wp_rest_server = null; + remove_filter('pre_http_request', [$this, 'record_http'], 10); + remove_filter('escalated_outbound_url_resolve', [$this, 'resolve_host'], 10); + Setting::set('webhook_url', ''); + Setting::set('webhook_secret', ''); + Setting::set('visibility', 'all'); + + parent::tear_down(); + } + + public function resolve_host($addresses, $host) + { + return $host === 'hooks.example.com' ? ['93.184.216.34'] : $addresses; + } + + public function record_http($preempt, $args, $url) + { + $this->http[] = ['url' => $url, 'args' => $args]; + + return [ + 'headers' => [], + 'body' => '', + 'response' => ['code' => 200, 'message' => 'OK'], + 'cookies' => [], + 'filename' => null, + ]; + } + + public function test_transfer_to_an_agent_reassigns_the_chat_and_ticket(): void + { + $session = $this->active_chat(); + $workflow_id = $this->workflow('chat.transferred'); + $this->settle(); + + $response = $this->transfer((int) $session->id, ['agent_id' => $this->to_agent]); + + $this->assertSame(200, $response->get_status()); + $data = $response->get_data(); + $this->assertSame('Chat transferred.', $data['message']); + $this->assertSame($this->to_agent, (int) $data['session']->agent_id); + + $fresh = ChatSession::find((int) $session->id); + $this->assertSame($this->to_agent, (int) $fresh->agent_id); + $this->assertSame('active', $fresh->status); + $this->assertSame($this->to_agent, (int) Ticket::find((int) $session->ticket_id)->assigned_to); + + $this->assertSame(['chat.transferred'], $this->delivered()); + $payload = $this->payload(0); + $this->assertSame((int) $session->ticket_id, $payload['ticket']['id']); + $this->assertSame((int) $session->id, $payload['chat']['session_id']); + $this->assertSame($this->from_agent, $payload['chat']['from_agent_id']); + $this->assertSame($this->to_agent, $payload['chat']['to_agent_id']); + $this->assertNull($payload['chat']['to_department_id']); + + $this->assertSame(1, $this->log_count($workflow_id)); + } + + public function test_department_only_transfer_requeues_and_reroutes(): void + { + $department = $this->department(); + $routed = $this->factory->user->create(['role' => 'escalated_agent']); + $session = $this->active_chat(); + ChatRoutingRule::create([ + 'name' => 'Billing', + 'department_id' => $department, + 'agent_id' => $routed, + 'priority' => 10, + 'is_active' => 1, + ]); + $this->settle(); + + $response = $this->transfer((int) $session->id, ['department_id' => $department]); + + $this->assertSame(200, $response->get_status()); + $fresh = ChatSession::find((int) $session->id); + $ticket = Ticket::find((int) $session->ticket_id); + $this->assertSame($department, (int) $ticket->department_id); + $this->assertSame($department, (int) $fresh->department_id); + $this->assertSame($routed, (int) $fresh->agent_id, 'routing should pick the new department\'s agent'); + $this->assertSame('active', $fresh->status); + $this->assertSame($routed, (int) $ticket->assigned_to); + + $this->assertSame('chat.transferred', $this->delivered()[0]); + $payload = $this->payload(0); + $this->assertSame($this->from_agent, $payload['chat']['from_agent_id']); + $this->assertNull($payload['chat']['to_agent_id']); + $this->assertSame($department, $payload['chat']['to_department_id']); + } + + public function test_department_only_transfer_with_no_agent_leaves_the_chat_waiting(): void + { + $department = $this->department(); + $session = $this->active_chat(); + + $response = $this->transfer((int) $session->id, ['department_id' => (string) $department]); + + $this->assertSame(200, $response->get_status()); + $fresh = ChatSession::find((int) $session->id); + $this->assertNull($fresh->agent_id); + $this->assertSame('waiting', $fresh->status); + $ticket = Ticket::find((int) $session->ticket_id); + $this->assertEmpty($ticket->assigned_to); + $this->assertSame($department, (int) $ticket->department_id); + } + + public function test_agent_and_department_together(): void + { + $department = $this->department(); + $session = $this->active_chat(); + + $response = $this->transfer((int) $session->id, ['agent_id' => $this->to_agent, 'department_id' => $department]); + + $this->assertSame(200, $response->get_status()); + $fresh = ChatSession::find((int) $session->id); + $this->assertSame($this->to_agent, (int) $fresh->agent_id); + $this->assertSame('active', $fresh->status); + $this->assertSame($department, (int) Ticket::find((int) $session->ticket_id)->department_id); + } + + public function test_the_chat_action_bar_body_is_accepted(): void + { + $department = $this->department(); + $session = $this->active_chat(); + + $this->assertSame(200, $this->transfer((int) $session->id, ['type' => 'agent', 'id' => $this->to_agent])->get_status()); + $this->assertSame($this->to_agent, (int) ChatSession::find((int) $session->id)->agent_id); + + $this->assertSame(200, $this->transfer((int) $session->id, ['type' => 'department', 'id' => $department])->get_status()); + $this->assertSame($department, (int) Ticket::find((int) $session->ticket_id)->department_id); + } + + public function test_refusals(): void + { + $session = $this->active_chat(); + $this->settle(); + + $request = new WP_REST_Request('POST', '/escalated/v1/chat/'.(int) $session->id.'/transfer'); + $request->set_body_params(['agent_id' => $this->to_agent]); + $this->assertSame(401, $this->server->dispatch($request)->get_status(), 'no token'); + + $this->assertSame(422, $this->transfer((int) $session->id, [])->get_status()); + $this->assertSame(422, $this->transfer((int) $session->id, ['agent_id' => '', 'department_id' => ''])->get_status()); + $this->assertSame(422, $this->transfer((int) $session->id, ['department_id' => 999999])->get_status()); + $this->assertSame(422, $this->transfer((int) $session->id, ['department_id' => 'billing'])->get_status()); + $this->assertSame(422, $this->transfer((int) $session->id, ['agent_id' => 999999])->get_status()); + $this->assertSame(404, $this->transfer(999999, ['agent_id' => $this->to_agent])->get_status()); + + (new ChatSessionService)->end((int) $session->id); + $this->settle(); + $this->assertSame(422, $this->transfer((int) $session->id, ['agent_id' => $this->to_agent])->get_status()); + + $this->assertSame($this->from_agent, (int) ChatSession::find((int) $session->id)->agent_id); + (new WebhookDispatcher)->process_due(); + $this->assertNotContains('chat.transferred', $this->delivered()); + } + + public function test_an_agent_who_cannot_access_the_ticket_is_refused(): void + { + $department = $this->department(); + Department::add_agent($department, $this->from_agent); + $session = $this->active_chat($department); + Setting::set('visibility', 'department'); + $this->settle(); + + $response = $this->transfer((int) $session->id, ['agent_id' => $this->to_agent]); + + $this->assertSame(422, $response->get_status()); + $this->assertSame($this->from_agent, (int) ChatSession::find((int) $session->id)->agent_id); + $this->assertSame([], $this->delivered()); + } + + public function test_chat_transferred_is_offered_to_webhooks_and_workflows(): void + { + $this->assertContains('chat.transferred', EventVocabulary::WEBHOOK_EVENTS); + $this->assertContains('chat.transferred', WorkflowEngine::TRIGGER_EVENTS); + $this->assertNotSame('chat.transferred', WorkflowAdmin::trigger_label('chat.transferred')); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private function transfer(int $session_id, array $body): WP_REST_Response + { + $request = new WP_REST_Request('POST', '/escalated/v1/chat/'.$session_id.'/transfer'); + $request->set_header('Authorization', 'Bearer '.$this->token); + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode((object) $body)); + + return rest_ensure_response($this->server->dispatch($request)); + } + + private function active_chat(?int $department = null): object + { + $chats = new ChatSessionService; + $session = $chats->start('Visitor', 'visitor@example.com', 'Hello', $department); + if ($session->status === 'waiting') { + $session = $chats->accept((int) $session->id, $this->from_agent); + } + + return $session; + } + + private function department(): int + { + $slug = 'xfer-'.strtolower(wp_generate_password(8, false, false)); + + return (int) Department::create([ + 'name' => $slug, + 'slug' => $slug, + 'description' => '', + 'is_active' => 1, + ]); + } + + private function workflow(string $trigger): int + { + global $wpdb; + $wpdb->insert(Workflow::table(), [ + 'name' => 'On '.$trigger, + 'trigger_event' => $trigger, + 'conditions' => null, + 'actions' => wp_json_encode([['type' => 'add_note', 'value' => 'transferred']]), + 'is_active' => 1, + 'position' => 0, + 'stop_on_match' => 0, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]); + + return (int) $wpdb->insert_id; + } + + private function log_count(int $workflow_id): int + { + global $wpdb; + $table = \Escalated\Escalated::table('workflow_logs'); + + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE workflow_id = %d", $workflow_id)); + } + + /** Sends what earlier steps queued and forgets it. */ + private function settle(): void + { + (new WebhookDispatcher)->process_due(); + $this->http = []; + } + + /** @return list */ + private function delivered(): array + { + (new WebhookDispatcher)->process_due(); + $events = []; + foreach ($this->http as $call) { + if ($call['url'] === self::WEBHOOK_URL) { + $events[] = json_decode($call['args']['body'], true)['event']; + } + } + + return $events; + } + + private function payload(int $index): array + { + $calls = array_values(array_filter($this->http, static fn ($c) => $c['url'] === self::WEBHOOK_URL)); + + return json_decode($calls[$index]['args']['body'], true)['payload']; + } +} diff --git a/tests/Test_Workflow_Wiring.php b/tests/Test_Workflow_Wiring.php index 9c53414..c54440a 100644 --- a/tests/Test_Workflow_Wiring.php +++ b/tests/Test_Workflow_Wiring.php @@ -48,6 +48,7 @@ class Test_Workflow_Wiring extends WP_UnitTestCase 'sla.breached', 'chat.started', 'chat.assigned', + 'chat.transferred', 'chat.message', 'chat.ended', ]; @@ -299,6 +300,8 @@ private function fire(string $trigger): void $chats->accept((int) $session->id, $this->agent_id); } elseif ($trigger === 'chat.message') { $chats->send_message((int) $session->id, 'Still there?'); + } elseif ($trigger === 'chat.transferred') { + $chats->transfer((int) $session->id, null, $this->make_department()); } elseif ($trigger === 'chat.ended') { $chats->end((int) $session->id); }