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