"""Tests for AutoCora job submission and result polling.""" from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest from cheddahbot.config import AutoCoraConfig, ClickUpConfig, Config from cheddahbot.tools.autocora import ( _group_by_keyword, _make_job_id, _parse_result, _slugify, poll_autocora_results, submit_autocora_jobs, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @dataclass class FakeTask: """Minimal stand-in for ClickUpTask.""" id: str name: str status: str = "to do" task_type: str = "Content Creation" due_date: str = "" custom_fields: dict[str, Any] = field(default_factory=dict) @pytest.fixture() def autocora_config(): return AutoCoraConfig(enabled=True) @pytest.fixture() def cfg(tmp_path, autocora_config): """Config with AutoCora pointing at tmp dirs.""" jobs_dir = tmp_path / "jobs" results_dir = tmp_path / "results" jobs_dir.mkdir() results_dir.mkdir() autocora_config.jobs_dir = str(jobs_dir) autocora_config.results_dir = str(results_dir) return Config( autocora=autocora_config, clickup=ClickUpConfig( api_token="test-token", workspace_id="ws1", space_id="sp1", task_type_field_name="Work Category", ), ) @pytest.fixture() def ctx(cfg, tmp_db): return {"config": cfg, "db": tmp_db} # --------------------------------------------------------------------------- # Helper tests # --------------------------------------------------------------------------- class TestSlugify: def test_basic(self): assert _slugify("Hello World") == "hello-world" def test_special_chars(self): assert _slugify("CNC machining & milling!") == "cnc-machining-milling" def test_multiple_spaces(self): assert _slugify(" too many spaces ") == "too-many-spaces" def test_truncation(self): result = _slugify("a" * 200) assert len(result) <= 80 class TestMakeJobId: def test_format(self): jid = _make_job_id("precision machining") assert jid.startswith("job-") assert "precision-machining" in jid def test_uniqueness(self): a = _make_job_id("test") b = _make_job_id("test") # Millisecond timestamp — may be equal if very fast, but format is correct assert a.startswith("job-") assert b.startswith("job-") class TestParseResult: def test_json_success(self): raw = json.dumps({"status": "SUCCESS", "task_ids": ["abc"]}) result = _parse_result(raw) assert result["status"] == "SUCCESS" assert result["task_ids"] == ["abc"] def test_json_failure(self): raw = json.dumps({"status": "FAILURE", "reason": "Cora not running", "task_ids": ["x"]}) result = _parse_result(raw) assert result["status"] == "FAILURE" assert result["reason"] == "Cora not running" def test_legacy_success(self): result = _parse_result("SUCCESS") assert result["status"] == "SUCCESS" def test_legacy_failure(self): result = _parse_result("FAILURE: timeout exceeded") assert result["status"] == "FAILURE" assert result["reason"] == "timeout exceeded" def test_unknown(self): result = _parse_result("some garbage") assert result["status"] == "UNKNOWN" class TestGroupByKeyword: def test_basic_grouping(self): tasks = [ FakeTask(id="t1", name="Task 1", custom_fields={"Keyword": "CNC", "IMSURL": "http://a.com"}), FakeTask(id="t2", name="Task 2", custom_fields={"Keyword": "cnc", "IMSURL": "http://a.com"}), ] groups, alerts = _group_by_keyword(tasks, tasks) assert len(groups) == 1 assert "cnc" in groups assert set(groups["cnc"]["task_ids"]) == {"t1", "t2"} assert alerts == [] def test_missing_keyword(self): tasks = [FakeTask(id="t1", name="No KW", custom_fields={"IMSURL": "http://a.com"})] groups, alerts = _group_by_keyword(tasks, tasks) assert len(groups) == 0 assert any("missing Keyword" in a for a in alerts) def test_missing_imsurl(self): tasks = [FakeTask(id="t1", name="No URL", custom_fields={"Keyword": "test"})] groups, alerts = _group_by_keyword(tasks, tasks) assert len(groups) == 0 assert any("missing IMSURL" in a for a in alerts) def test_sibling_tasks(self): """Tasks sharing a keyword from all_tasks should be included.""" due_tasks = [ FakeTask(id="t1", name="Due Task", custom_fields={"Keyword": "CNC", "IMSURL": "http://a.com"}), ] all_tasks = [ FakeTask(id="t1", name="Due Task", custom_fields={"Keyword": "CNC", "IMSURL": "http://a.com"}), FakeTask(id="t2", name="Sibling", custom_fields={"Keyword": "cnc", "IMSURL": "http://a.com"}), FakeTask(id="t3", name="Other KW", custom_fields={"Keyword": "welding", "IMSURL": "http://b.com"}), ] groups, alerts = _group_by_keyword(due_tasks, all_tasks) assert set(groups["cnc"]["task_ids"]) == {"t1", "t2"} assert "welding" not in groups # --------------------------------------------------------------------------- # Submit tool tests # --------------------------------------------------------------------------- class TestSubmitAutocoraJobs: def test_disabled(self, ctx): ctx["config"].autocora.enabled = False result = submit_autocora_jobs(ctx=ctx) assert "disabled" in result.lower() def test_no_context(self): result = submit_autocora_jobs() assert "Error" in result def test_no_qualifying_tasks(self, ctx, monkeypatch): monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [] ) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "No qualifying tasks" in result def test_submit_writes_job_file(self, ctx, monkeypatch, tmp_path): """Valid tasks produce a job JSON file on disk.""" task = FakeTask( id="t1", name="CNC Page", due_date="1700000000000", custom_fields={"Keyword": "CNC Machining", "IMSURL": "http://example.com"}, ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task] ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task] ) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "Submitted 1 job" in result # Check job file exists jobs_dir = Path(ctx["config"].autocora.jobs_dir) job_files = list(jobs_dir.glob("job-*.json")) assert len(job_files) == 1 # Verify contents job_data = json.loads(job_files[0].read_text()) assert job_data["keyword"] == "CNC Machining" assert job_data["url"] == "http://example.com" assert job_data["task_ids"] == ["t1"] def test_submit_tracks_kv(self, ctx, monkeypatch): """KV store tracks submitted jobs.""" task = FakeTask( id="t1", name="Test", due_date="1700000000000", custom_fields={"Keyword": "test keyword", "IMSURL": "http://example.com"}, ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task] ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task] ) submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) raw = ctx["db"].kv_get("autocora:job:test keyword") assert raw is not None state = json.loads(raw) assert state["status"] == "submitted" assert "t1" in state["task_ids"] def test_duplicate_prevention(self, ctx, monkeypatch): """Already-submitted keywords are skipped.""" task = FakeTask( id="t1", name="Test", due_date="1700000000000", custom_fields={"Keyword": "test", "IMSURL": "http://example.com"}, ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task] ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task] ) # First submit submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) # Second submit — should skip result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "Skipped 1" in result def test_missing_keyword_alert(self, ctx, monkeypatch): """Tasks without Keyword field produce alerts.""" task = FakeTask( id="t1", name="No KW Task", due_date="1700000000000", custom_fields={"IMSURL": "http://example.com"}, ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task] ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task] ) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "missing Keyword" in result def test_missing_imsurl_alert(self, ctx, monkeypatch): """Tasks without IMSURL field produce alerts.""" task = FakeTask( id="t1", name="No URL Task", due_date="1700000000000", custom_fields={"Keyword": "test"}, ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task] ) monkeypatch.setattr( "cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task] ) result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx) assert "missing IMSURL" in result # --------------------------------------------------------------------------- # Poll tool tests # --------------------------------------------------------------------------- class TestPollAutocoraResults: def test_disabled(self, ctx): ctx["config"].autocora.enabled = False result = poll_autocora_results(ctx=ctx) assert "disabled" in result.lower() def test_no_pending(self, ctx): result = poll_autocora_results(ctx=ctx) assert "No pending" in result def test_success_json(self, ctx, monkeypatch): """JSON SUCCESS result updates KV and ClickUp.""" db = ctx["db"] results_dir = Path(ctx["config"].autocora.results_dir) # Set up submitted job in KV job_id = "job-123-test" kv_key = "autocora:job:test keyword" db.kv_set( kv_key, json.dumps({ "status": "submitted", "job_id": job_id, "keyword": "test keyword", "task_ids": ["t1", "t2"], }), ) # Write result file result_data = {"status": "SUCCESS", "task_ids": ["t1", "t2"]} (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) # Mock ClickUp client mock_client = MagicMock() monkeypatch.setattr( "cheddahbot.tools.autocora._get_clickup_client", lambda ctx: mock_client ) result = poll_autocora_results(ctx=ctx) assert "SUCCESS: test keyword" in result # Verify KV updated state = json.loads(db.kv_get(kv_key)) assert state["status"] == "completed" # Verify ClickUp calls assert mock_client.update_task_status.call_count == 2 mock_client.update_task_status.assert_any_call("t1", "running cora") mock_client.update_task_status.assert_any_call("t2", "running cora") assert mock_client.add_comment.call_count == 2 def test_failure_json(self, ctx, monkeypatch): """JSON FAILURE result updates KV and ClickUp with error.""" db = ctx["db"] results_dir = Path(ctx["config"].autocora.results_dir) job_id = "job-456-fail" kv_key = "autocora:job:fail keyword" db.kv_set( kv_key, json.dumps({ "status": "submitted", "job_id": job_id, "keyword": "fail keyword", "task_ids": ["t3"], }), ) result_data = { "status": "FAILURE", "reason": "Cora not running", "task_ids": ["t3"], } (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) mock_client = MagicMock() monkeypatch.setattr( "cheddahbot.tools.autocora._get_clickup_client", lambda ctx: mock_client ) result = poll_autocora_results(ctx=ctx) assert "FAILURE: fail keyword" in result assert "Cora not running" in result state = json.loads(db.kv_get(kv_key)) assert state["status"] == "failed" assert state["error"] == "Cora not running" mock_client.update_task_status.assert_called_once_with("t3", "error") def test_legacy_plain_text(self, ctx, monkeypatch): """Legacy plain-text SUCCESS result still works.""" db = ctx["db"] results_dir = Path(ctx["config"].autocora.results_dir) job_id = "job-789-legacy" kv_key = "autocora:job:legacy kw" db.kv_set( kv_key, json.dumps({ "status": "submitted", "job_id": job_id, "keyword": "legacy kw", "task_ids": ["t5"], }), ) # Legacy format — plain text, no JSON (results_dir / f"{job_id}.result").write_text("SUCCESS") mock_client = MagicMock() monkeypatch.setattr( "cheddahbot.tools.autocora._get_clickup_client", lambda ctx: mock_client ) result = poll_autocora_results(ctx=ctx) assert "SUCCESS: legacy kw" in result # task_ids come from KV fallback mock_client.update_task_status.assert_called_once_with("t5", "running cora") def test_task_ids_from_result_preferred(self, ctx, monkeypatch): """task_ids from result file take precedence over KV.""" db = ctx["db"] results_dir = Path(ctx["config"].autocora.results_dir) job_id = "job-100-pref" kv_key = "autocora:job:pref kw" db.kv_set( kv_key, json.dumps({ "status": "submitted", "job_id": job_id, "keyword": "pref kw", "task_ids": ["old_t1"], # KV has old IDs }), ) # Result has updated task_ids result_data = {"status": "SUCCESS", "task_ids": ["new_t1", "new_t2"]} (results_dir / f"{job_id}.result").write_text(json.dumps(result_data)) mock_client = MagicMock() monkeypatch.setattr( "cheddahbot.tools.autocora._get_clickup_client", lambda ctx: mock_client ) poll_autocora_results(ctx=ctx) # Should use result file task_ids, not KV calls = [c.args for c in mock_client.update_task_status.call_args_list] assert ("new_t1", "running cora") in calls assert ("new_t2", "running cora") in calls assert ("old_t1", "running cora") not in calls def test_still_pending(self, ctx): """Jobs without result files show as still pending.""" db = ctx["db"] db.kv_set( "autocora:job:waiting", json.dumps({ "status": "submitted", "job_id": "job-999-wait", "keyword": "waiting", "task_ids": ["t99"], }), ) result = poll_autocora_results(ctx=ctx) assert "Still pending" in result assert "waiting" in result