CheddahBot/tests/test_clickup.py

572 lines
19 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_parses_due_date(self):
data = {
"id": "x",
"name": "Test",
"status": {"status": "open", "type": "open"},
"due_date": "1740000000000",
"custom_fields": [],
}
task = ClickUpTask.from_api(data)
assert task.due_date == "1740000000000"
def test_due_date_defaults_empty_when_null(self):
data = {
"id": "x",
"name": "Test",
"status": {"status": "open", "type": "open"},
"due_date": None,
"custom_fields": [],
}
task = ClickUpTask.from_api(data)
assert task.due_date == ""
def test_due_date_defaults_empty_when_missing(self):
data = {
"id": "x",
"name": "Test",
"status": {"status": "open", "type": "open"},
"custom_fields": [],
}
task = ClickUpTask.from_api(data)
assert task.due_date == ""
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()
@respx.mock
def test_get_tasks_with_due_date_and_custom_fields(self):
"""Verify due_date_lt and custom_fields are passed as query params."""
route = respx.get(f"{BASE_URL}/list/list_1/task").mock(
return_value=httpx.Response(200, json={"tasks": []})
)
client = ClickUpClient(api_token="pk_test")
client.get_tasks(
"list_1",
statuses=["to do"],
due_date_lt=1740000000000,
custom_fields='[{"field_id":"cf_1","operator":"ANY","value":["opt_1"]}]',
)
request = route.calls.last.request
url_str = str(request.url)
assert "due_date_lt=1740000000000" in url_str
assert "custom_fields=" in url_str
client.close()
@respx.mock
def test_discover_field_filter_found(self):
respx.get(f"{BASE_URL}/list/list_1/field").mock(
return_value=httpx.Response(
200,
json={
"fields": [
{
"id": "cf_wc",
"name": "Work Category",
"type": "drop_down",
"type_config": {
"options": [
{"id": "opt_pr", "name": "Press Release", "orderindex": 0},
{"id": "opt_lb", "name": "Link Building", "orderindex": 1},
]
},
},
{"id": "cf_other", "name": "Company", "type": "short_text"},
]
},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.discover_field_filter("list_1", "Work Category")
assert result is not None
assert result["field_id"] == "cf_wc"
assert result["options"]["Press Release"] == "opt_pr"
assert result["options"]["Link Building"] == "opt_lb"
client.close()
@respx.mock
def test_discover_field_filter_not_found(self):
respx.get(f"{BASE_URL}/list/list_1/field").mock(
return_value=httpx.Response(
200,
json={"fields": [{"id": "cf_other", "name": "Company", "type": "short_text"}]},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.discover_field_filter("list_1", "Work Category")
assert result is None
client.close()
@respx.mock
def test_create_task(self):
respx.post(f"{BASE_URL}/list/list_1/task").mock(
return_value=httpx.Response(
200,
json={
"id": "new_task_1",
"name": "Test Task",
"url": "https://app.clickup.com/t/new_task_1",
},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.create_task(
list_id="list_1",
name="Test Task",
description="A test description",
status="to do",
)
assert result["id"] == "new_task_1"
assert result["url"] == "https://app.clickup.com/t/new_task_1"
request = respx.calls.last.request
import json
body = json.loads(request.content)
assert body["name"] == "Test Task"
assert body["description"] == "A test description"
assert body["status"] == "to do"
client.close()
@respx.mock
def test_create_task_with_optional_fields(self):
respx.post(f"{BASE_URL}/list/list_1/task").mock(
return_value=httpx.Response(
200,
json={"id": "new_task_2", "name": "Tagged Task", "url": ""},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.create_task(
list_id="list_1",
name="Tagged Task",
due_date=1740000000000,
tags=["urgent", "mar26"],
custom_fields=[{"id": "cf_1", "value": "opt_1"}],
)
assert result["id"] == "new_task_2"
import json
body = json.loads(respx.calls.last.request.content)
assert body["due_date"] == 1740000000000
assert body["tags"] == ["urgent", "mar26"]
assert body["custom_fields"] == [{"id": "cf_1", "value": "opt_1"}]
client.close()
@respx.mock
def test_find_list_in_folder_found(self):
respx.get(f"{BASE_URL}/space/space_1/folder").mock(
return_value=httpx.Response(
200,
json={
"folders": [
{
"id": "f1",
"name": "Acme Corp",
"lists": [
{"id": "list_overall", "name": "Overall"},
{"id": "list_archive", "name": "Archive"},
],
},
{
"id": "f2",
"name": "Widget Co",
"lists": [
{"id": "list_w_overall", "name": "Overall"},
],
},
]
},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.find_list_in_folder("space_1", "Acme Corp")
assert result == "list_overall"
client.close()
@respx.mock
def test_find_list_in_folder_case_insensitive(self):
respx.get(f"{BASE_URL}/space/space_1/folder").mock(
return_value=httpx.Response(
200,
json={
"folders": [
{
"id": "f1",
"name": "Acme Corp",
"lists": [{"id": "list_overall", "name": "Overall"}],
},
]
},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.find_list_in_folder("space_1", "acme corp")
assert result == "list_overall"
client.close()
@respx.mock
def test_find_list_in_folder_not_found(self):
respx.get(f"{BASE_URL}/space/space_1/folder").mock(
return_value=httpx.Response(
200,
json={
"folders": [
{
"id": "f1",
"name": "Acme Corp",
"lists": [{"id": "list_1", "name": "Overall"}],
},
]
},
)
)
client = ClickUpClient(api_token="pk_test")
result = client.find_list_in_folder("space_1", "NonExistent Client")
assert result is None
client.close()