342 lines
12 KiB
Python
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()
|