642 lines
22 KiB
Python
642 lines
22 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()
|
|
|
|
@respx.mock
|
|
def test_set_custom_field_smart_dropdown(self):
|
|
"""Resolves dropdown option name to UUID automatically."""
|
|
respx.get(f"{BASE_URL}/list/list_1/field").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"fields": [
|
|
{
|
|
"id": "cf_lb",
|
|
"name": "LB Method",
|
|
"type": "drop_down",
|
|
"type_config": {
|
|
"options": [
|
|
{"id": "opt_cora", "name": "Cora Backlinks"},
|
|
{"id": "opt_manual", "name": "Manual"},
|
|
]
|
|
},
|
|
},
|
|
]
|
|
},
|
|
)
|
|
)
|
|
respx.post(f"{BASE_URL}/task/t1/field/cf_lb").mock(
|
|
return_value=httpx.Response(200, json={})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test")
|
|
result = client.set_custom_field_smart(
|
|
"t1", "list_1", "LB Method", "Cora Backlinks"
|
|
)
|
|
assert result is True
|
|
import json
|
|
|
|
body = json.loads(respx.calls.last.request.content)
|
|
assert body["value"] == "opt_cora"
|
|
client.close()
|
|
|
|
@respx.mock
|
|
def test_set_custom_field_smart_text(self):
|
|
"""Passes text field values through without resolution."""
|
|
respx.get(f"{BASE_URL}/list/list_1/field").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"fields": [
|
|
{
|
|
"id": "cf_kw",
|
|
"name": "Keyword",
|
|
"type": "short_text",
|
|
},
|
|
]
|
|
},
|
|
)
|
|
)
|
|
respx.post(f"{BASE_URL}/task/t1/field/cf_kw").mock(
|
|
return_value=httpx.Response(200, json={})
|
|
)
|
|
|
|
client = ClickUpClient(api_token="pk_test")
|
|
result = client.set_custom_field_smart(
|
|
"t1", "list_1", "Keyword", "shaft manufacturing"
|
|
)
|
|
assert result is True
|
|
import json
|
|
|
|
body = json.loads(respx.calls.last.request.content)
|
|
assert body["value"] == "shaft manufacturing"
|
|
client.close()
|