526 lines
19 KiB
Python
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]
|