336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""Tests for the ClickUp REST API client and task parsing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from cheddahbot.clickup import BASE_URL, ClickUpClient, ClickUpTask
|
|
|
|
# ── ClickUpTask.from_api ──
|
|
|
|
|
|
class TestClickUpTaskParsing:
|
|
def test_parses_basic_fields(self, sample_clickup_task_data):
|
|
task = ClickUpTask.from_api(sample_clickup_task_data)
|
|
|
|
assert task.id == "abc123"
|
|
assert task.name == "Write PR for Acme Corp"
|
|
assert task.status == "to do"
|
|
assert task.description == "Draft a press release about the new product launch."
|
|
assert task.url == "https://app.clickup.com/t/abc123"
|
|
assert task.list_id == "list_1"
|
|
assert task.list_name == "Content Tasks"
|
|
|
|
def test_resolves_dropdown_by_orderindex(self, sample_clickup_task_data):
|
|
task = ClickUpTask.from_api(sample_clickup_task_data)
|
|
|
|
assert task.task_type == "Press Release"
|
|
assert task.custom_fields["Task Type"] == "Press Release"
|
|
|
|
def test_preserves_text_custom_fields(self, sample_clickup_task_data):
|
|
task = ClickUpTask.from_api(sample_clickup_task_data)
|
|
|
|
assert task.custom_fields["Company"] == "Acme Corp"
|
|
|
|
def test_handles_null_dropdown_value(self, sample_clickup_task_data):
|
|
task = ClickUpTask.from_api(sample_clickup_task_data)
|
|
|
|
assert task.custom_fields["Priority Level"] is None
|
|
|
|
def test_no_custom_fields(self, sample_clickup_task_no_type):
|
|
task = ClickUpTask.from_api(sample_clickup_task_no_type)
|
|
|
|
assert task.task_type == ""
|
|
assert task.custom_fields == {}
|
|
|
|
def test_custom_task_type_field_name(self, sample_clickup_task_data):
|
|
"""When task_type_field_name doesn't match, task_type should be empty."""
|
|
task = ClickUpTask.from_api(sample_clickup_task_data, task_type_field_name="Work Type")
|
|
|
|
assert task.task_type == ""
|
|
# But custom fields still parsed
|
|
assert task.custom_fields["Task Type"] == "Press Release"
|
|
|
|
def test_status_normalized_to_lowercase(self):
|
|
data = {
|
|
"id": "x",
|
|
"name": "Test",
|
|
"status": {"status": "In Progress", "type": "active"},
|
|
"custom_fields": [],
|
|
}
|
|
task = ClickUpTask.from_api(data)
|
|
assert task.status == "in progress"
|
|
|
|
def test_missing_description_defaults_empty(self):
|
|
data = {
|
|
"id": "x",
|
|
"name": "Test",
|
|
"status": {"status": "open", "type": "open"},
|
|
"custom_fields": [],
|
|
}
|
|
task = ClickUpTask.from_api(data)
|
|
assert task.description == ""
|
|
|
|
def test_dropdown_resolved_by_id_fallback(self):
|
|
"""When orderindex doesn't match, fall back to id matching."""
|
|
data = {
|
|
"id": "x",
|
|
"name": "Test",
|
|
"status": {"status": "open", "type": "open"},
|
|
"custom_fields": [
|
|
{
|
|
"id": "cf_1",
|
|
"name": "Task Type",
|
|
"type": "drop_down",
|
|
"value": "opt_2", # string id, not int orderindex
|
|
"type_config": {
|
|
"options": [
|
|
{"id": "opt_1", "name": "Press Release", "orderindex": 0},
|
|
{"id": "opt_2", "name": "Link Building", "orderindex": 1},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
}
|
|
task = ClickUpTask.from_api(data)
|
|
assert task.custom_fields["Task Type"] == "Link Building"
|
|
assert task.task_type == "Link Building"
|
|
|
|
|
|
# ── ClickUpClient ──
|
|
|
|
|
|
class TestClickUpClient:
|
|
@respx.mock
|
|
def test_get_tasks(self):
|
|
respx.get(f"{BASE_URL}/list/list_1/task").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"tasks": [
|
|
{
|
|
"id": "t1",
|
|
"name": "Task One",
|
|
"status": {"status": "to do", "type": "open"},
|
|
"custom_fields": [],
|
|
},
|
|
{
|
|
"id": "t2",
|
|
"name": "Task Two",
|
|
"status": {"status": "to do", "type": "open"},
|
|
"custom_fields": [],
|
|
},
|
|
]
|
|
},
|
|
)
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
tasks = client.get_tasks("list_1", statuses=["to do"])
|
|
|
|
assert len(tasks) == 2
|
|
assert tasks[0].id == "t1"
|
|
assert tasks[1].name == "Task Two"
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_get_tasks_from_space(self):
|
|
# Mock folders endpoint
|
|
respx.get(f"{BASE_URL}/space/space_1/folder").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"folders": [
|
|
{
|
|
"id": "f1",
|
|
"lists": [{"id": "list_a"}, {"id": "list_b"}],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
)
|
|
# Mock folderless lists
|
|
respx.get(f"{BASE_URL}/space/space_1/list").mock(
|
|
return_value=httpx.Response(200, json={"lists": [{"id": "list_c"}]})
|
|
)
|
|
# Mock task responses for each list
|
|
for list_id in ("list_a", "list_b", "list_c"):
|
|
respx.get(f"{BASE_URL}/list/{list_id}/task").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"tasks": [
|
|
{
|
|
"id": f"t_{list_id}",
|
|
"name": f"Task from {list_id}",
|
|
"status": {"status": "to do", "type": "open"},
|
|
"custom_fields": [],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
tasks = client.get_tasks_from_space("space_1", statuses=["to do"])
|
|
|
|
assert len(tasks) == 3
|
|
task_ids = {t.id for t in tasks}
|
|
assert task_ids == {"t_list_a", "t_list_b", "t_list_c"}
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_update_task_status(self):
|
|
respx.put(f"{BASE_URL}/task/t1").mock(return_value=httpx.Response(200, json={}))
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.update_task_status("t1", "in progress")
|
|
|
|
assert result is True
|
|
request = respx.calls.last.request
|
|
assert b'"status"' in request.content
|
|
assert b'"in progress"' in request.content
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_update_task_status_failure(self):
|
|
respx.put(f"{BASE_URL}/task/t1").mock(
|
|
return_value=httpx.Response(403, json={"err": "forbidden"})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.update_task_status("t1", "done")
|
|
|
|
assert result is False
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_add_comment(self):
|
|
respx.post(f"{BASE_URL}/task/t1/comment").mock(return_value=httpx.Response(200, json={}))
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.add_comment("t1", "CheddahBot completed this task.")
|
|
|
|
assert result is True
|
|
request = respx.calls.last.request
|
|
assert b"CheddahBot completed" in request.content
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_add_comment_failure(self):
|
|
respx.post(f"{BASE_URL}/task/t1/comment").mock(
|
|
return_value=httpx.Response(500, json={"err": "server error"})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.add_comment("t1", "Hello")
|
|
|
|
assert result is False
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_get_custom_fields(self):
|
|
respx.get(f"{BASE_URL}/list/list_1/field").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"fields": [
|
|
{"id": "cf_1", "name": "Task Type", "type": "drop_down"},
|
|
{"id": "cf_2", "name": "Company", "type": "short_text"},
|
|
]
|
|
},
|
|
)
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
fields = client.get_custom_fields("list_1")
|
|
|
|
assert len(fields) == 2
|
|
assert fields[0]["name"] == "Task Type"
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_upload_attachment_success(self, tmp_path):
|
|
docx_file = tmp_path / "report.docx"
|
|
docx_file.write_bytes(b"fake docx content")
|
|
|
|
respx.post(f"{BASE_URL}/task/t1/attachment").mock(return_value=httpx.Response(200, json={}))
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.upload_attachment("t1", docx_file)
|
|
|
|
assert result is True
|
|
request = respx.calls.last.request
|
|
assert b"report.docx" in request.content
|
|
# Multipart upload should NOT have application/json content-type
|
|
assert "multipart/form-data" in request.headers.get("content-type", "")
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_upload_attachment_http_failure(self, tmp_path):
|
|
docx_file = tmp_path / "report.docx"
|
|
docx_file.write_bytes(b"fake docx content")
|
|
|
|
respx.post(f"{BASE_URL}/task/t1/attachment").mock(
|
|
return_value=httpx.Response(403, json={"err": "forbidden"})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.upload_attachment("t1", docx_file)
|
|
|
|
assert result is False
|
|
client.close()
|
|
|
|
def test_upload_attachment_file_not_found(self):
|
|
client = ClickUpClient(api_token="pk_test_123")
|
|
result = client.upload_attachment("t1", "/nonexistent/file.docx")
|
|
|
|
assert result is False
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_auth_header_sent(self):
|
|
respx.get(f"{BASE_URL}/list/list_1/task").mock(
|
|
return_value=httpx.Response(200, json={"tasks": []})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_secret_token")
|
|
client.get_tasks("list_1")
|
|
|
|
request = respx.calls.last.request
|
|
assert request.headers["authorization"] == "pk_secret_token"
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_get_tasks_from_space_handles_folder_error(self):
|
|
"""If folders endpoint fails, still fetch folderless lists."""
|
|
respx.get(f"{BASE_URL}/space/space_1/folder").mock(
|
|
return_value=httpx.Response(403, json={"err": "forbidden"})
|
|
)
|
|
respx.get(f"{BASE_URL}/space/space_1/list").mock(
|
|
return_value=httpx.Response(200, json={"lists": [{"id": "list_x"}]})
|
|
)
|
|
respx.get(f"{BASE_URL}/list/list_x/task").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"tasks": [
|
|
{
|
|
"id": "t_x",
|
|
"name": "Task X",
|
|
"status": {"status": "to do", "type": "open"},
|
|
"custom_fields": [],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test")
|
|
tasks = client.get_tasks_from_space("space_1")
|
|
|
|
assert len(tasks) == 1
|
|
assert tasks[0].id == "t_x"
|
|
client.close()
|