CheddahBot/tests/test_clickup_runner/test_clickup_client.py

342 lines
12 KiB
Python

"""Tests for clickup_runner.clickup_client.
Unit tests use respx to mock HTTP. Integration tests hit the real API.
"""
import pytest
import respx
import httpx
from clickup_runner.clickup_client import ClickUpClient, ClickUpTask, BASE_URL
# ── Fixtures ──
@pytest.fixture
def sample_task_data():
"""A realistic ClickUp API task response."""
return {
"id": "task_001",
"name": "Plumber SEO Page - Miami",
"status": {"status": "to do"},
"description": "Write optimized content for plumber services in Miami",
"url": "https://app.clickup.com/t/task_001",
"due_date": "1711929600000", # some timestamp
"list": {"id": "list_100", "name": "Overall"},
"folder": {"id": "fold_1", "name": "Acme Plumbing"},
"tags": [{"name": "mar26"}, {"name": "content"}],
"custom_fields": [
{
"id": "cf_wc",
"name": "Work Category",
"type": "drop_down",
"value": "opt_cc",
"type_config": {
"options": [
{"id": "opt_cc", "name": "Content Creation", "orderindex": 0},
{"id": "opt_pr", "name": "Press Release", "orderindex": 1},
{"id": "opt_lb", "name": "Link Building", "orderindex": 2},
]
},
},
{
"id": "cf_stage",
"name": "Stage",
"type": "drop_down",
"value": "opt_outline",
"type_config": {
"options": [
{"id": "opt_runcora", "name": "run_cora", "orderindex": 0},
{"id": "opt_outline", "name": "outline", "orderindex": 1},
{"id": "opt_draft", "name": "draft", "orderindex": 2},
]
},
},
{
"id": "cf_delegate",
"name": "Delegate to Claude",
"type": "checkbox",
"value": True,
},
{
"id": "cf_error",
"name": "Error",
"type": "checkbox",
"value": False,
},
{
"id": "cf_keyword",
"name": "Keyword",
"type": "short_text",
"value": "plumber miami",
},
{
"id": "cf_imsurl",
"name": "IMSURL",
"type": "url",
"value": "https://acmeplumbing.com/miami",
},
],
}
@pytest.fixture
def sample_task(sample_task_data):
return ClickUpTask.from_api(sample_task_data)
# ── ClickUpTask parsing tests ──
class TestClickUpTaskFromApi:
def test_basic_fields(self, sample_task):
assert sample_task.id == "task_001"
assert sample_task.name == "Plumber SEO Page - Miami"
assert sample_task.status == "to do"
assert sample_task.list_id == "list_100"
assert sample_task.folder_name == "Acme Plumbing"
def test_dropdown_resolved_to_label(self, sample_task):
assert sample_task.task_type == "Content Creation"
assert sample_task.custom_fields["Stage"] == "outline"
def test_checkbox_fields(self, sample_task):
assert sample_task.custom_fields["Delegate to Claude"] is True
assert sample_task.custom_fields["Error"] is False
def test_text_fields(self, sample_task):
assert sample_task.custom_fields["Keyword"] == "plumber miami"
assert sample_task.custom_fields["IMSURL"] == "https://acmeplumbing.com/miami"
def test_tags(self, sample_task):
assert "mar26" in sample_task.tags
assert "content" in sample_task.tags
def test_due_date(self, sample_task):
assert sample_task.due_date == "1711929600000"
def test_missing_due_date(self, sample_task_data):
sample_task_data["due_date"] = None
task = ClickUpTask.from_api(sample_task_data)
assert task.due_date == ""
def test_missing_custom_fields(self):
task = ClickUpTask.from_api({"id": "t1", "name": "test"})
assert task.task_type == ""
assert task.custom_fields == {}
def test_unknown_dropdown_value_kept_as_is(self, sample_task_data):
"""If dropdown value doesn't match any option, keep raw value."""
sample_task_data["custom_fields"][0]["value"] = "unknown_opt_id"
task = ClickUpTask.from_api(sample_task_data)
assert task.task_type == "unknown_opt_id"
class TestClickUpTaskHelpers:
def test_get_field_value(self, sample_task):
assert sample_task.get_field_value("Keyword") == "plumber miami"
assert sample_task.get_field_value("Nonexistent") is None
def test_has_xlsx_attachment_false_when_empty(self, sample_task):
sample_task.attachments = []
assert not sample_task.has_xlsx_attachment()
def test_has_xlsx_attachment_true(self, sample_task):
sample_task.attachments = [
{"title": "report.xlsx", "url": "https://..."}
]
assert sample_task.has_xlsx_attachment()
def test_has_xlsx_attachment_case_insensitive(self, sample_task):
sample_task.attachments = [
{"title": "Report.XLSX", "url": "https://..."}
]
assert sample_task.has_xlsx_attachment()
# ── ClickUpClient tests (respx mocked) ──
class TestClientCheckbox:
def test_is_checkbox_checked_true(self, sample_task):
client = ClickUpClient(api_token="fake")
assert client.is_checkbox_checked(sample_task, "Delegate to Claude")
def test_is_checkbox_checked_false(self, sample_task):
client = ClickUpClient(api_token="fake")
assert not client.is_checkbox_checked(sample_task, "Error")
def test_is_checkbox_checked_missing_field(self, sample_task):
client = ClickUpClient(api_token="fake")
assert not client.is_checkbox_checked(sample_task, "Nonexistent")
def test_is_checkbox_checked_string_true(self, sample_task):
client = ClickUpClient(api_token="fake")
sample_task.custom_fields["Delegate to Claude"] = "true"
assert client.is_checkbox_checked(sample_task, "Delegate to Claude")
def test_is_checkbox_checked_string_false(self, sample_task):
client = ClickUpClient(api_token="fake")
sample_task.custom_fields["Delegate to Claude"] = "false"
assert not client.is_checkbox_checked(sample_task, "Delegate to Claude")
class TestClientStage:
def test_get_stage(self, sample_task):
client = ClickUpClient(api_token="fake")
assert client.get_stage(sample_task) == "outline"
def test_get_stage_empty(self):
client = ClickUpClient(api_token="fake")
task = ClickUpTask(id="t1", name="test", status="to do")
assert client.get_stage(task) == ""
def test_get_stage_custom_field_name(self, sample_task):
client = ClickUpClient(api_token="fake")
sample_task.custom_fields["Custom Stage"] = "DRAFT"
assert client.get_stage(sample_task, field_name="Custom Stage") == "draft"
@respx.mock
class TestClientHTTP:
def test_get_task(self):
task_data = {
"id": "t1",
"name": "Test",
"status": {"status": "to do"},
}
respx.get(f"{BASE_URL}/task/t1").mock(
return_value=httpx.Response(200, json=task_data)
)
client = ClickUpClient(api_token="fake")
task = client.get_task("t1")
assert task.id == "t1"
assert task.name == "Test"
client.close()
def test_update_task_status(self):
respx.put(f"{BASE_URL}/task/t1").mock(
return_value=httpx.Response(200, json={})
)
client = ClickUpClient(api_token="fake")
assert client.update_task_status("t1", "ai working") is True
client.close()
def test_update_task_status_failure(self):
respx.put(f"{BASE_URL}/task/t1").mock(
return_value=httpx.Response(404, json={"err": "not found"})
)
client = ClickUpClient(api_token="fake")
assert client.update_task_status("t1", "ai working") is False
client.close()
def test_add_comment(self):
respx.post(f"{BASE_URL}/task/t1/comment").mock(
return_value=httpx.Response(200, json={})
)
client = ClickUpClient(api_token="fake")
assert client.add_comment("t1", "hello") is True
client.close()
def test_get_folders(self):
respx.get(f"{BASE_URL}/space/sp1/folder").mock(
return_value=httpx.Response(200, json={
"folders": [
{
"id": "f1",
"name": "Acme",
"lists": [
{"id": "l1", "name": "Overall"},
{"id": "l2", "name": "Archive"},
],
}
]
})
)
client = ClickUpClient(api_token="fake")
folders = client.get_folders("sp1")
assert len(folders) == 1
assert folders[0]["name"] == "Acme"
assert len(folders[0]["lists"]) == 2
client.close()
def test_get_tasks_from_overall_lists(self):
# Mock folders endpoint
respx.get(f"{BASE_URL}/space/sp1/folder").mock(
return_value=httpx.Response(200, json={
"folders": [
{
"id": "f1",
"name": "Client A",
"lists": [
{"id": "l1", "name": "Overall"},
{"id": "l2", "name": "Archive"},
],
},
{
"id": "f2",
"name": "Client B",
"lists": [
{"id": "l3", "name": "Overall"},
],
},
]
})
)
# Mock task endpoints -- only Overall lists should be hit
respx.get(f"{BASE_URL}/list/l1/task").mock(
return_value=httpx.Response(200, json={
"tasks": [
{"id": "t1", "name": "Task 1", "status": {"status": "to do"}},
]
})
)
respx.get(f"{BASE_URL}/list/l3/task").mock(
return_value=httpx.Response(200, json={
"tasks": [
{"id": "t2", "name": "Task 2", "status": {"status": "review"}},
]
})
)
# l2 (Archive) should NOT be called
client = ClickUpClient(api_token="fake")
tasks = client.get_tasks_from_overall_lists("sp1")
assert len(tasks) == 2
ids = {t.id for t in tasks}
assert ids == {"t1", "t2"}
client.close()
def test_retry_on_5xx(self):
route = respx.put(f"{BASE_URL}/task/t1")
route.side_effect = [
httpx.Response(500, json={"err": "internal"}),
httpx.Response(200, json={}),
]
client = ClickUpClient(api_token="fake")
assert client.update_task_status("t1", "ai working") is True
client.close()
def test_no_retry_on_4xx(self):
respx.put(f"{BASE_URL}/task/t1").mock(
return_value=httpx.Response(400, json={"err": "bad request"})
)
client = ClickUpClient(api_token="fake")
assert client.update_task_status("t1", "ai working") is False
client.close()
def test_get_task_attachments(self):
respx.get(f"{BASE_URL}/task/t1/attachment").mock(
return_value=httpx.Response(200, json={
"attachments": [
{"id": "a1", "title": "report.xlsx", "url": "https://..."},
]
})
)
client = ClickUpClient(api_token="fake")
atts = client.get_task_attachments("t1")
assert len(atts) == 1
assert atts[0]["title"] == "report.xlsx"
client.close()