arcade-mcp/toolkits/zendesk/tests/test_tickets.py
Eric Gustin ca11ec9fa3
Add toolkits (#514)
Add Linkedin, Zendesk, Math, and Postgres toolkits
2025-07-25 15:44:06 -07:00

526 lines
19 KiB
Python

from unittest.mock import MagicMock
import httpx
import pytest
from arcade_core.errors import ToolExecutionError
from arcade_zendesk.enums import SortOrder, TicketStatus
from arcade_zendesk.tools.tickets import (
add_ticket_comment,
get_ticket_comments,
list_tickets,
mark_ticket_solved,
)
class TestListTickets:
"""Test list_tickets functionality."""
@pytest.mark.asyncio
async def test_list_tickets_success(self, mock_context, mock_httpx_client, mock_http_response):
"""Test successful listing of open tickets."""
mock_context.get_secret.return_value = "test-subdomain"
# Mock response data - includes url field that should be removed
tickets_response = {
"tickets": [
{
"id": 1,
"subject": "Login issue",
"status": "open",
"url": "https://test-subdomain.zendesk.com/api/v2/tickets/1.json",
},
{
"id": 2,
"subject": "Password reset request",
"status": "open",
"url": "https://test-subdomain.zendesk.com/api/v2/tickets/2.json",
},
]
}
mock_httpx_client.get.return_value = mock_http_response(tickets_response)
result = await list_tickets(mock_context, status=TicketStatus.OPEN)
# Verify the result is structured data
assert isinstance(result, dict)
assert "tickets" in result
assert "count" in result
assert result["count"] == 2
# Verify tickets have html_url but not url
for ticket in result["tickets"]:
assert "url" not in ticket
assert "html_url" in ticket
assert ticket["html_url"].startswith(
"https://test-subdomain.zendesk.com/agent/tickets/"
)
# Verify the API call with default parameters
mock_httpx_client.get.assert_called()
# The fetch_paginated_results makes the actual call
call_args = mock_httpx_client.get.call_args
assert "https://test-subdomain.zendesk.com/api/v2/tickets.json" in call_args[0][0]
assert call_args[1]["params"]["status"] == "open"
assert call_args[1]["params"]["per_page"] == 100
assert call_args[1]["params"]["sort_order"] == "desc"
assert call_args[1]["params"]["page"] == 1 # First page
@pytest.mark.asyncio
async def test_list_tickets_with_offset_limit(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test listing tickets with offset and limit."""
mock_context.get_secret.return_value = "test-subdomain"
# Mock response for page 2 (offset 10, limit 5)
tickets_response = {
"tickets": [
{"id": 11, "subject": "Test 11", "status": "open"},
{"id": 12, "subject": "Test 12", "status": "open"},
{"id": 13, "subject": "Test 13", "status": "open"},
{"id": 14, "subject": "Test 14", "status": "open"},
{"id": 15, "subject": "Test 15", "status": "open"},
],
"next_page": "https://test.zendesk.com/api/v2/tickets.json?page=3",
}
mock_httpx_client.get.return_value = mock_http_response(tickets_response)
result = await list_tickets(
mock_context, status=TicketStatus.OPEN, limit=5, offset=10, sort_order=SortOrder.ASC
)
# Verify response structure
assert result["count"] == 5
assert len(result["tickets"]) == 5
assert "next_offset" in result
assert result["next_offset"] == 15 # offset + limit
# Verify API call parameters
call_args = mock_httpx_client.get.call_args
assert (
call_args[1]["params"]["page"] == 2
) # offset 10 / per_page 100 = page 2 (but adjusted for limit)
assert call_args[1]["params"]["per_page"] == 100
assert call_args[1]["params"]["sort_order"] == "asc"
@pytest.mark.asyncio
async def test_list_tickets_no_more_results(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test listing tickets when no more results are available."""
mock_context.get_secret.return_value = "test-subdomain"
# Mock response with no next_page - simulating the last page
tickets_response = {
"tickets": [{"id": 21, "subject": "Test", "status": "pending"}],
# No next_page means no more results
}
mock_httpx_client.get.return_value = mock_http_response(tickets_response)
result = await list_tickets(
mock_context,
status=TicketStatus.PENDING,
limit=10,
offset=0, # Start from beginning
)
# Verify no next_offset when no more results
assert "next_offset" not in result
assert result["count"] == 1
# Verify API call parameters
call_args = mock_httpx_client.get.call_args
assert call_args[1]["params"]["status"] == "pending"
assert call_args[1]["params"]["page"] == 1
@pytest.mark.asyncio
async def test_list_tickets_no_tickets(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test when no tickets are found."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.get.return_value = mock_http_response({"tickets": []})
result = await list_tickets(mock_context, status=TicketStatus.OPEN)
assert result["tickets"] == []
assert result["count"] == 0
@pytest.mark.asyncio
async def test_list_tickets_error(self, mock_context, mock_httpx_client):
"""Test error handling for failed API call."""
mock_context.get_secret.return_value = "test-subdomain"
# Mock error response that raise_for_status will catch
error_response = MagicMock()
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Unauthorized", request=MagicMock(), response=MagicMock(status_code=401)
)
mock_httpx_client.get.return_value = error_response
with pytest.raises(ToolExecutionError):
await list_tickets(mock_context)
@pytest.mark.asyncio
async def test_list_tickets_no_subdomain(self, mock_context):
"""Test when subdomain is not configured."""
mock_context.get_secret.return_value = None
with pytest.raises(ToolExecutionError):
await list_tickets(mock_context)
class TestGetTicketComments:
"""Test get_ticket_comments functionality."""
@pytest.mark.asyncio
async def test_get_ticket_comments_success(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test successfully getting ticket comments."""
mock_context.get_secret.return_value = "test-subdomain"
# Mock response data
comments_response = {
"comments": [
{
"id": 1,
"body": "I cannot access my account. Please help!",
"author_id": 12345,
"created_at": "2024-01-15T10:00:00Z",
"public": True,
"attachments": [],
},
{
"id": 2,
"body": "I'll help you reset your password.",
"author_id": 67890,
"created_at": "2024-01-15T10:30:00Z",
"public": True,
"attachments": [
{
"file_name": "screenshot.png",
"content_url": "https://example.com/screenshot.png",
"size": 12345,
}
],
},
]
}
mock_httpx_client.get.return_value = mock_http_response(comments_response)
result = await get_ticket_comments(mock_context, ticket_id=123)
# Verify the result is structured data
assert isinstance(result, dict)
assert result["ticket_id"] == 123
assert result["count"] == 2
assert len(result["comments"]) == 2
# Verify attachments are included
assert result["comments"][1]["attachments"][0]["file_name"] == "screenshot.png"
# Verify the API call
mock_httpx_client.get.assert_called_once_with(
"https://test-subdomain.zendesk.com/api/v2/tickets/123/comments.json",
headers={
"Authorization": "Bearer fake-token",
"Content-Type": "application/json",
},
)
@pytest.mark.asyncio
async def test_get_ticket_comments_no_comments(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test when no comments are found."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.get.return_value = mock_http_response({"comments": []})
result = await get_ticket_comments(mock_context, ticket_id=123)
assert result["comments"] == []
assert result["count"] == 0
@pytest.mark.asyncio
async def test_get_ticket_comments_not_found(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test when ticket is not found."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.get.return_value = mock_http_response({}, status_code=404)
with pytest.raises(ToolExecutionError):
await get_ticket_comments(mock_context, ticket_id=999)
@pytest.mark.asyncio
async def test_get_ticket_comments_error(self, mock_context, mock_httpx_client):
"""Test error handling when API fails."""
mock_context.get_secret.return_value = "test-subdomain"
error_response = MagicMock()
error_response.status_code = 500
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock(status_code=500)
)
mock_httpx_client.get.return_value = error_response
with pytest.raises(ToolExecutionError):
await get_ticket_comments(mock_context, ticket_id=123)
class TestAddTicketComment:
"""Test add_ticket_comment functionality."""
@pytest.mark.asyncio
async def test_add_public_comment_success(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test successfully adding a public comment."""
mock_context.get_secret.return_value = "test-subdomain"
ticket_response = {
"ticket": {
"id": 123,
"subject": "Test ticket",
"url": "https://test-subdomain.zendesk.com/api/v2/tickets/123.json",
}
}
mock_httpx_client.put.return_value = mock_http_response(ticket_response)
result = await add_ticket_comment(
mock_context,
ticket_id=123,
comment_body="This is a test comment",
public=True,
)
# Verify structured response
assert isinstance(result, dict)
assert result["success"] is True
assert result["ticket_id"] == 123
assert result["comment_type"] == "public"
assert "ticket" in result
# Verify ticket has html_url but not url
assert "url" not in result["ticket"]
assert "html_url" in result["ticket"]
# Verify the API call
mock_httpx_client.put.assert_called_once_with(
"https://test-subdomain.zendesk.com/api/v2/tickets/123.json",
headers={
"Authorization": "Bearer fake-token",
"Content-Type": "application/json",
},
json={"ticket": {"comment": {"body": "This is a test comment", "public": True}}},
)
@pytest.mark.asyncio
async def test_add_comment_default_public(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test that comment defaults to public when not specified."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 123}})
result = await add_ticket_comment(
mock_context,
ticket_id=123,
comment_body="Test comment",
# Not specifying public parameter
)
assert result["comment_type"] == "public"
# Verify the API call has public=True
call_args = mock_httpx_client.put.call_args
assert call_args[1]["json"]["ticket"]["comment"]["public"] is True
@pytest.mark.asyncio
async def test_add_internal_comment_success(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test successfully adding an internal comment."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 456}})
result = await add_ticket_comment(
mock_context,
ticket_id=456,
comment_body="Internal note for agents",
public=False,
)
assert result["comment_type"] == "internal"
@pytest.mark.asyncio
async def test_add_comment_error(self, mock_context, mock_httpx_client):
"""Test error handling when adding comment fails."""
mock_context.get_secret.return_value = "test-subdomain"
error_response = MagicMock()
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
)
mock_httpx_client.put.return_value = error_response
with pytest.raises(ToolExecutionError):
await add_ticket_comment(mock_context, ticket_id=999, comment_body="Test comment")
class TestMarkTicketSolved:
"""Test mark_ticket_solved functionality."""
@pytest.mark.asyncio
async def test_mark_solved_without_comment(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test marking ticket as solved without a comment."""
mock_context.get_secret.return_value = "test-subdomain"
ticket_response = {
"ticket": {
"id": 789,
"status": "solved",
"url": "https://test-subdomain.zendesk.com/api/v2/tickets/789.json",
}
}
mock_httpx_client.put.return_value = mock_http_response(ticket_response)
result = await mark_ticket_solved(mock_context, ticket_id=789)
# Verify structured response
assert isinstance(result, dict)
assert result["success"] is True
assert result["ticket_id"] == 789
assert result["status"] == "solved"
assert "comment_added" not in result
# Verify ticket has html_url
assert "html_url" in result["ticket"]
assert "url" not in result["ticket"]
# Verify the API call
mock_httpx_client.put.assert_called_once_with(
"https://test-subdomain.zendesk.com/api/v2/tickets/789.json",
headers={
"Authorization": "Bearer fake-token",
"Content-Type": "application/json",
},
json={"ticket": {"status": "solved"}},
)
@pytest.mark.asyncio
async def test_mark_solved_with_public_comment(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test marking ticket as solved with a public comment."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 123}})
result = await mark_ticket_solved(
mock_context,
ticket_id=123,
comment_body="Issue resolved by resetting password",
comment_public=True,
)
assert result["comment_added"] is True
assert result["comment_type"] == "public"
# Verify the request body includes the comment
call_args = mock_httpx_client.put.call_args
request_body = call_args[1]["json"]
assert request_body["ticket"]["status"] == "solved"
assert request_body["ticket"]["comment"]["body"] == "Issue resolved by resetting password"
assert request_body["ticket"]["comment"]["public"] is True
@pytest.mark.asyncio
async def test_mark_solved_with_comment_default_internal(
self, mock_context, mock_httpx_client, mock_http_response
):
"""Test marking ticket as solved with comment defaults to internal."""
mock_context.get_secret.return_value = "test-subdomain"
mock_httpx_client.put.return_value = mock_http_response({"ticket": {"id": 555}})
result = await mark_ticket_solved(
mock_context,
ticket_id=555,
comment_body="Internal resolution note",
# Not specifying comment_public, should default to False
)
assert result["comment_added"] is True
assert result["comment_type"] == "internal"
# Verify the comment is internal by default
call_args = mock_httpx_client.put.call_args
request_body = call_args[1]["json"]
assert request_body["ticket"]["comment"]["public"] is False
@pytest.mark.asyncio
async def test_mark_solved_error(self, mock_context, mock_httpx_client):
"""Test error handling when marking ticket as solved fails."""
mock_context.get_secret.return_value = "test-subdomain"
error_response = MagicMock()
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Forbidden", request=MagicMock(), response=MagicMock(status_code=403)
)
mock_httpx_client.put.return_value = error_response
with pytest.raises(ToolExecutionError):
await mark_ticket_solved(mock_context, ticket_id=999)
class TestAuthenticationAndSecrets:
"""Test authentication and secrets handling."""
@pytest.mark.asyncio
async def test_no_auth_token(self, mock_context, mock_httpx_client):
"""Test behavior when auth token is empty."""
mock_context.get_auth_token_or_empty.return_value = ""
mock_context.get_secret.return_value = "test-subdomain"
# The tools should still attempt the API call with empty token
error_response = MagicMock()
error_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Unauthorized", request=MagicMock(), response=MagicMock(status_code=401)
)
mock_httpx_client.get.return_value = error_response
with pytest.raises(ToolExecutionError):
await list_tickets(mock_context)
# Should be called with empty Bearer token
call_args = mock_httpx_client.get.call_args
assert call_args[1]["headers"]["Authorization"] == "Bearer "
@pytest.mark.asyncio
async def test_subdomain_from_secret(self, mock_context, mock_httpx_client, mock_http_response):
"""Test that subdomain is correctly retrieved from secrets."""
mock_context.get_secret.return_value = "my-company"
mock_httpx_client.get.return_value = mock_http_response({"tickets": []})
await list_tickets(mock_context, status=TicketStatus.OPEN)
# Verify the correct subdomain was used
call_args = mock_httpx_client.get.call_args
assert "https://my-company.zendesk.com" in call_args[0][0]