Rename Client field to Customer and refine Overview sections
ClickUp "Client" custom field was deleted; switch all references to "Customer" across config, tools, tests, and docs. Refine Overview tab: rename Due Soon to Up Next (today/tomorrow, fallback next 5), expand This Month to include both month tag and due-date-in-month matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>cora-start
parent
0d60b1b516
commit
f67f1b9124
|
|
@ -105,7 +105,7 @@ uv add --group test <package>
|
||||||
- **Memory scoping**: Agents with `memory_scope` set use `memory/{scope}/` subdirectory. Empty scope = shared `memory/` root. Fallback search checks both scoped and shared directories.
|
- **Memory scoping**: Agents with `memory_scope` set use `memory/{scope}/` subdirectory. Empty scope = shared `memory/` root. Fallback search checks both scoped and shared directories.
|
||||||
- **Database**: SQLite with WAL mode, thread-local connections via `threading.local()`
|
- **Database**: SQLite with WAL mode, thread-local connections via `threading.local()`
|
||||||
- **KV store**: Task state stored as JSON at `clickup:task:{id}:state` keys
|
- **KV store**: Task state stored as JSON at `clickup:task:{id}:state` keys
|
||||||
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Client` field (not `Company`) holds the client name.
|
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Customer` field holds the client name.
|
||||||
- **Notifications**: All scheduler events go through `NotificationBus.push()`, never directly to a UI
|
- **Notifications**: All scheduler events go through `NotificationBus.push()`, never directly to a UI
|
||||||
- **Tests**: Use `respx` to mock httpx calls, `tmp_db` fixture for isolated SQLite instances
|
- **Tests**: Use `respx` to mock httpx calls, `tmp_db` fixture for isolated SQLite instances
|
||||||
- **ClickUp attachments**: `ClickUpClient.upload_attachment()` uses module-level `httpx.post()` (not the shared client) for multipart uploads
|
- **ClickUp attachments**: `ClickUpClient.upload_attachment()` uses module-level `httpx.post()` (not the shared client) for multipart uploads
|
||||||
|
|
@ -175,7 +175,7 @@ skill_map:
|
||||||
auto_execute: true
|
auto_execute: true
|
||||||
field_mapping:
|
field_mapping:
|
||||||
topic: "task_name" # uses ClickUp task name
|
topic: "task_name" # uses ClickUp task name
|
||||||
company_name: "Client" # looks up "Client" custom field
|
company_name: "Customer" # looks up "Customer" custom field
|
||||||
```
|
```
|
||||||
|
|
||||||
Task lifecycle: `to do` → discovered → approved/awaiting_approval → executing → completed/failed (+ attachments uploaded)
|
Task lifecycle: `to do` → discovered → approved/awaiting_approval → executing → completed/failed (+ attachments uploaded)
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ def _find_clickup_task(ctx: dict, company_name: str) -> str:
|
||||||
if task.task_type != "Press Release":
|
if task.task_type != "Press Release":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
client_field = task.custom_fields.get("Client", "")
|
client_field = task.custom_fields.get("Customer", "")
|
||||||
if not (
|
if not (
|
||||||
_fuzzy_company_match(company_name, task.name)
|
_fuzzy_company_match(company_name, task.name)
|
||||||
or _fuzzy_company_match(company_name, client_field)
|
or _fuzzy_company_match(company_name, client_field)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ clickup:
|
||||||
auto_execute: true
|
auto_execute: true
|
||||||
field_mapping:
|
field_mapping:
|
||||||
topic: "task_name"
|
topic: "task_name"
|
||||||
company_name: "Client"
|
company_name: "Customer"
|
||||||
target_url: "IMSURL"
|
target_url: "IMSURL"
|
||||||
branded_url: "SocialURL"
|
branded_url: "SocialURL"
|
||||||
"Link Building":
|
"Link Building":
|
||||||
|
|
|
||||||
|
|
@ -125,13 +125,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Due Soon (next 14 days) -->
|
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
|
||||||
<div class="section section--tight">
|
<div class="section section--tight">
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
<h2 class="section__title"><span class="icon">⏰</span> Due Soon</h2>
|
<h2 class="section__title"><span class="icon">⏰</span> Up Next</h2>
|
||||||
<span class="section__badge" id="due-soon-count">-</span>
|
<span class="section__badge" id="up-next-count">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-table-wrap task-table-wrap--compact" id="overview-due-soon">
|
<div class="task-table-wrap task-table-wrap--compact" id="overview-up-next">
|
||||||
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -393,29 +393,55 @@ async function loadOverview() {
|
||||||
document.getElementById('stat-agents-detail').textContent = 'Configured';
|
document.getElementById('stat-agents-detail').textContent = 'Configured';
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Due Soon: tasks due within 14 days --
|
// -- Up Next: tasks due today/tomorrow, or next 5 by due date --
|
||||||
if (tasks && tasks.tasks) {
|
if (tasks && tasks.tasks) {
|
||||||
const now = Date.now();
|
const now = new Date();
|
||||||
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
const dueSoon = tasks.tasks
|
const endOfTomorrow = startOfToday + 2 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Only consider open tasks
|
||||||
|
const openTasks = tasks.tasks.filter(t => {
|
||||||
|
const s = (t.status || '').toLowerCase();
|
||||||
|
return !(s.includes('complete') || s.includes('done') || s.includes('closed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
let upNext = openTasks
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!t.due_date) return false;
|
if (!t.due_date) return false;
|
||||||
const due = parseInt(t.due_date, 10);
|
const due = parseInt(t.due_date, 10);
|
||||||
return due > now && due <= now + fourteenDays;
|
return due >= startOfToday && due < endOfTomorrow;
|
||||||
})
|
})
|
||||||
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date));
|
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date));
|
||||||
const dueSoonIds = new Set(dueSoon.map(t => t.id));
|
|
||||||
document.getElementById('due-soon-count').textContent = dueSoon.length;
|
|
||||||
renderOverviewTable('overview-due-soon', dueSoon, true);
|
|
||||||
|
|
||||||
// -- This Month: tasks tagged with current month tag, excluding due-soon --
|
// Fallback: if nothing due today/tomorrow, show next 5 with a due date
|
||||||
|
if (upNext.length === 0) {
|
||||||
|
upNext = openTasks
|
||||||
|
.filter(t => t.due_date && parseInt(t.due_date, 10) >= startOfToday)
|
||||||
|
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date))
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
document.getElementById('up-next-count').textContent = upNext.length;
|
||||||
|
renderOverviewTable('overview-up-next', upNext, true);
|
||||||
|
|
||||||
|
// -- This Month: tagged with month tag OR due date this month --
|
||||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||||
const d = new Date();
|
const monthTag = monthNames[now.getMonth()] + String(now.getFullYear()).slice(2);
|
||||||
const monthTag = monthNames[d.getMonth()] + String(d.getFullYear()).slice(2);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
||||||
const thisMonth = tasks.tasks.filter(t => {
|
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
||||||
if (dueSoonIds.has(t.id)) return false;
|
|
||||||
return (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
|
const thisMonthSet = new Set();
|
||||||
});
|
const thisMonth = [];
|
||||||
|
for (const t of openTasks) {
|
||||||
|
if (thisMonthSet.has(t.id)) continue;
|
||||||
|
const hasTag = (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
|
||||||
|
const dueThisMonth = t.due_date &&
|
||||||
|
parseInt(t.due_date, 10) >= startOfMonth &&
|
||||||
|
parseInt(t.due_date, 10) < startOfNextMonth;
|
||||||
|
if (hasTag || dueThisMonth) {
|
||||||
|
thisMonth.push(t);
|
||||||
|
thisMonthSet.add(t.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
document.getElementById('this-month-count').textContent = thisMonth.length;
|
document.getElementById('this-month-count').textContent = thisMonth.length;
|
||||||
renderOverviewTable('overview-this-month', thisMonth, false);
|
renderOverviewTable('overview-this-month', thisMonth, false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""Tests for the simplified ClickUp scheduler flow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from cheddahbot.clickup import ClickUpTask
|
||||||
|
from cheddahbot.scheduler import Scheduler
|
||||||
|
|
||||||
|
# ── Fixtures ──
|
||||||
|
|
||||||
|
|
||||||
|
_PR_MAPPING = {
|
||||||
|
"tool": "write_press_releases",
|
||||||
|
"auto_execute": True,
|
||||||
|
"field_mapping": {
|
||||||
|
"topic": "task_name",
|
||||||
|
"company_name": "Customer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeClickUpConfig:
|
||||||
|
api_token: str = "pk_test"
|
||||||
|
workspace_id: str = "ws_1"
|
||||||
|
space_id: str = "space_1"
|
||||||
|
poll_interval_minutes: int = 20
|
||||||
|
poll_statuses: list[str] = field(default_factory=lambda: ["to do"])
|
||||||
|
review_status: str = "internal review"
|
||||||
|
in_progress_status: str = "in progress"
|
||||||
|
task_type_field_name: str = "Work Category"
|
||||||
|
default_auto_execute: bool = True
|
||||||
|
skill_map: dict = field(default_factory=lambda: {"Press Release": _PR_MAPPING})
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeSchedulerConfig:
|
||||||
|
poll_interval_seconds: int = 60
|
||||||
|
heartbeat_interval_minutes: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeConfig:
|
||||||
|
clickup: _FakeClickUpConfig = field(default_factory=_FakeClickUpConfig)
|
||||||
|
scheduler: _FakeSchedulerConfig = field(default_factory=_FakeSchedulerConfig)
|
||||||
|
identity_dir: str = "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(task_id, name, task_type, due_date="", custom_fields=None):
|
||||||
|
"""Build a ClickUpTask for testing."""
|
||||||
|
return ClickUpTask(
|
||||||
|
id=task_id,
|
||||||
|
name=name,
|
||||||
|
status="to do",
|
||||||
|
task_type=task_type,
|
||||||
|
due_date=due_date,
|
||||||
|
custom_fields=custom_fields or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms():
|
||||||
|
return int(datetime.now(UTC).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
_FIELDS = {"Customer": "Acme"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ──
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollClickup:
|
||||||
|
"""Test the simplified _poll_clickup method."""
|
||||||
|
|
||||||
|
def test_skips_task_with_no_space_id(self, tmp_db):
|
||||||
|
config = _FakeConfig()
|
||||||
|
config.clickup.space_id = ""
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
# Should not raise — just log warning and return
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
def test_skips_task_with_empty_skill_map(self, tmp_db):
|
||||||
|
config = _FakeConfig()
|
||||||
|
config.clickup.skill_map = {}
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
# Should skip without calling any API
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
def _make_mock_client(self, tasks=None, field_filter=None):
|
||||||
|
"""Create a mock ClickUp client with proper return types."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_tasks_from_space.return_value = tasks or []
|
||||||
|
mock_client.get_list_ids_from_space.return_value = {"list_1"}
|
||||||
|
mock_client.discover_field_filter.return_value = field_filter
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
def test_skips_task_already_completed(self, tmp_db):
|
||||||
|
"""Tasks with completed state should be skipped."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
state = {"state": "completed", "clickup_task_id": "t1"}
|
||||||
|
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date=due,
|
||||||
|
custom_fields=_FIELDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler._clickup_client = self._make_mock_client(
|
||||||
|
tasks=[task],
|
||||||
|
)
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_task_already_failed(self, tmp_db):
|
||||||
|
"""Tasks with failed state should be skipped."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
state = {"state": "failed", "clickup_task_id": "t1"}
|
||||||
|
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date=due,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler._clickup_client = self._make_mock_client(
|
||||||
|
tasks=[task],
|
||||||
|
)
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_task_with_no_due_date(self, tmp_db):
|
||||||
|
"""Tasks with no due date should be skipped."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date="",
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler._clickup_client = self._make_mock_client(
|
||||||
|
tasks=[task],
|
||||||
|
)
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_unmapped_task_type(self, tmp_db):
|
||||||
|
"""Tasks with unmapped task_type should be skipped."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"Content task",
|
||||||
|
"Content Creation",
|
||||||
|
due_date=due,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler._clickup_client = self._make_mock_client(
|
||||||
|
tasks=[task],
|
||||||
|
)
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
|
||||||
|
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteTask:
|
||||||
|
"""Test the simplified _execute_task method."""
|
||||||
|
|
||||||
|
def test_success_flow(self, tmp_db):
|
||||||
|
"""Successful execution: state=completed."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
agent._tools = MagicMock()
|
||||||
|
agent._tools.execute.return_value = "## ClickUp Sync\nDone"
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.update_task_status.return_value = True
|
||||||
|
scheduler._clickup_client = mock_client
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date=due,
|
||||||
|
custom_fields=_FIELDS,
|
||||||
|
)
|
||||||
|
scheduler._execute_task(task)
|
||||||
|
|
||||||
|
mock_client.update_task_status.assert_any_call(
|
||||||
|
"t1",
|
||||||
|
"in progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||||
|
state = json.loads(raw)
|
||||||
|
assert state["state"] == "completed"
|
||||||
|
|
||||||
|
def test_success_fallback_path(self, tmp_db):
|
||||||
|
"""Scheduler uploads docx and sets review status."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
agent._tools = MagicMock()
|
||||||
|
agent._tools.execute.return_value = "Press releases done.\n**Docx:** `output/pr.docx`"
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.update_task_status.return_value = True
|
||||||
|
mock_client.upload_attachment.return_value = True
|
||||||
|
mock_client.add_comment.return_value = True
|
||||||
|
scheduler._clickup_client = mock_client
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date=due,
|
||||||
|
custom_fields=_FIELDS,
|
||||||
|
)
|
||||||
|
scheduler._execute_task(task)
|
||||||
|
|
||||||
|
mock_client.update_task_status.assert_any_call(
|
||||||
|
"t1",
|
||||||
|
"internal review",
|
||||||
|
)
|
||||||
|
mock_client.upload_attachment.assert_called_once_with(
|
||||||
|
"t1",
|
||||||
|
"output/pr.docx",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||||
|
state = json.loads(raw)
|
||||||
|
assert state["state"] == "completed"
|
||||||
|
assert "output/pr.docx" in state["deliverable_paths"]
|
||||||
|
|
||||||
|
def test_failure_flow(self, tmp_db):
|
||||||
|
"""Failed: state=failed, error comment, back to 'to do'."""
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
agent._tools = MagicMock()
|
||||||
|
agent._tools.execute.side_effect = RuntimeError("API timeout")
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.update_task_status.return_value = True
|
||||||
|
mock_client.add_comment.return_value = True
|
||||||
|
scheduler._clickup_client = mock_client
|
||||||
|
|
||||||
|
due = str(_now_ms() + 86400000)
|
||||||
|
task = _make_task(
|
||||||
|
"t1",
|
||||||
|
"PR for Acme",
|
||||||
|
"Press Release",
|
||||||
|
due_date=due,
|
||||||
|
custom_fields=_FIELDS,
|
||||||
|
)
|
||||||
|
scheduler._execute_task(task)
|
||||||
|
|
||||||
|
mock_client.update_task_status.assert_any_call("t1", "to do")
|
||||||
|
mock_client.add_comment.assert_called_once()
|
||||||
|
comment_text = mock_client.add_comment.call_args[0][1]
|
||||||
|
assert "failed" in comment_text.lower()
|
||||||
|
|
||||||
|
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||||
|
state = json.loads(raw)
|
||||||
|
assert state["state"] == "failed"
|
||||||
|
assert "API timeout" in state["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldFilterDiscovery:
|
||||||
|
"""Test _discover_field_filter caching."""
|
||||||
|
|
||||||
|
def test_caches_after_first_call(self, tmp_db):
|
||||||
|
config = _FakeConfig()
|
||||||
|
agent = MagicMock()
|
||||||
|
scheduler = Scheduler(config, tmp_db, agent)
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.get_list_ids_from_space.return_value = {"list_1"}
|
||||||
|
mock_client.discover_field_filter.return_value = {
|
||||||
|
"field_id": "cf_wc",
|
||||||
|
"options": {"Press Release": "opt_pr"},
|
||||||
|
}
|
||||||
|
mock_client.get_tasks_from_space.return_value = []
|
||||||
|
scheduler._clickup_client = mock_client
|
||||||
|
|
||||||
|
# First poll should discover
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
assert scheduler._field_filter_cache is not None
|
||||||
|
assert scheduler._field_filter_cache["field_id"] == "cf_wc"
|
||||||
|
|
||||||
|
# Second poll reuses cache
|
||||||
|
mock_client.discover_field_filter.reset_mock()
|
||||||
|
scheduler._poll_clickup()
|
||||||
|
mock_client.discover_field_filter.assert_not_called()
|
||||||
Loading…
Reference in New Issue