"""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()