Skip to main content

devboy_clickup/
client.rs

1//! ClickUp API client implementation.
2
3use async_trait::async_trait;
4use devboy_core::{
5    AssetCapabilities, AssetMeta, Comment, ContextCapabilities, CreateIssueInput, Error, Issue,
6    IssueFilter, IssueLink, IssueProvider, IssueRelations, IssueStatus, MergeRequestProvider,
7    PipelineProvider, Provider, ProviderResult, Result, SortInfo, SortOrder, UpdateIssueInput,
8    User,
9};
10use secrecy::{ExposeSecret, SecretString};
11use tracing::{debug, warn};
12
13use crate::DEFAULT_CLICKUP_URL;
14use crate::types::{
15    ClickUpAttachment, ClickUpComment, ClickUpCommentList, ClickUpLinkedTask, ClickUpListInfo,
16    ClickUpPriority, ClickUpTask, ClickUpTaskList, ClickUpUser, CreateCommentRequest,
17    CreateCommentResponse, CreateTaskRequest, UpdateTaskRequest,
18};
19
20/// Maximum number of tasks per page in ClickUp API.
21const PAGE_SIZE: u32 = 100;
22
23/// Percent-encode a tag name for use in ClickUp URL paths.
24fn encode_tag(tag: &str) -> String {
25    urlencoding::encode(tag).into_owned()
26}
27
28pub struct ClickUpClient {
29    base_url: String,
30    list_id: String,
31    team_id: Option<String>,
32    token: SecretString,
33    client: reqwest::Client,
34}
35
36impl ClickUpClient {
37    /// Create a new ClickUp client.
38    pub fn new(list_id: impl Into<String>, token: SecretString) -> Self {
39        Self::with_base_url(DEFAULT_CLICKUP_URL, list_id, token)
40    }
41
42    /// Create a new ClickUp client with a custom base URL (for testing).
43    pub fn with_base_url(
44        base_url: impl Into<String>,
45        list_id: impl Into<String>,
46        token: SecretString,
47    ) -> Self {
48        Self {
49            base_url: base_url.into().trim_end_matches('/').to_string(),
50            list_id: list_id.into(),
51            team_id: None,
52            token,
53            client: reqwest::Client::builder()
54                .user_agent("devboy-tools")
55                .build()
56                .expect("Failed to create HTTP client"),
57        }
58    }
59
60    /// Set team (workspace) ID — required for custom task ID resolution.
61    pub fn with_team_id(mut self, team_id: impl Into<String>) -> Self {
62        self.team_id = Some(team_id.into());
63        self
64    }
65
66    /// Build request with common headers.
67    fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
68        self.client
69            .request(method, url)
70            .header("Authorization", self.token.expose_secret())
71            .header("Content-Type", "application/json")
72    }
73
74    /// Make an authenticated GET request.
75    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
76        debug!(url = url, "ClickUp GET request");
77
78        let response = self
79            .request(reqwest::Method::GET, url)
80            .send()
81            .await
82            .map_err(|e| Error::Http(e.to_string()))?;
83
84        self.handle_response(response).await
85    }
86
87    /// Make an authenticated GET request with properly encoded query parameters.
88    async fn get_with_query<T: serde::de::DeserializeOwned>(
89        &self,
90        url: &str,
91        params: &[(&str, &str)],
92    ) -> Result<T> {
93        debug!(url = url, params = ?params, "ClickUp GET request with query");
94
95        let response = self
96            .request(reqwest::Method::GET, url)
97            .query(params)
98            .send()
99            .await
100            .map_err(|e| Error::Http(e.to_string()))?;
101
102        self.handle_response(response).await
103    }
104
105    /// Make an authenticated POST request.
106    async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
107        &self,
108        url: &str,
109        body: &B,
110    ) -> Result<T> {
111        debug!(url = url, "ClickUp POST request");
112
113        let response = self
114            .request(reqwest::Method::POST, url)
115            .json(body)
116            .send()
117            .await
118            .map_err(|e| Error::Http(e.to_string()))?;
119
120        self.handle_response(response).await
121    }
122
123    /// Make an authenticated PUT request.
124    async fn put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
125        &self,
126        url: &str,
127        body: &B,
128    ) -> Result<T> {
129        debug!(url = url, "ClickUp PUT request");
130
131        let response = self
132            .request(reqwest::Method::PUT, url)
133            .json(body)
134            .send()
135            .await
136            .map_err(|e| Error::Http(e.to_string()))?;
137
138        self.handle_response(response).await
139    }
140
141    /// Make an authenticated DELETE request.
142    /// Returns `Ok(())` on success. Treats 404 as success (idempotent delete).
143    async fn delete(&self, url: &str) -> Result<()> {
144        debug!(url = url, "ClickUp DELETE request");
145
146        let response = self
147            .request(reqwest::Method::DELETE, url)
148            .send()
149            .await
150            .map_err(|e| Error::Http(e.to_string()))?;
151
152        let status = response.status();
153        if status == reqwest::StatusCode::NOT_FOUND {
154            // 404 = already removed, treat as success
155            return Ok(());
156        }
157        if !status.is_success() {
158            let status_code = status.as_u16();
159            let message = response.text().await.unwrap_or_default();
160            warn!(
161                status = status_code,
162                message = message,
163                "ClickUp API error response"
164            );
165            return Err(Error::from_status(status_code, message));
166        }
167        Ok(())
168    }
169
170    /// Make an authenticated DELETE request with query parameters.
171    /// Returns `Ok(())` on success. Treats 404 as success (idempotent delete).
172    async fn delete_with_query(&self, url: &str, params: &[(&str, &str)]) -> Result<()> {
173        debug!(url = url, params = ?params, "ClickUp DELETE request with query");
174
175        let response = self
176            .request(reqwest::Method::DELETE, url)
177            .query(params)
178            .send()
179            .await
180            .map_err(|e| Error::Http(e.to_string()))?;
181
182        let status = response.status();
183        if status == reqwest::StatusCode::NOT_FOUND {
184            return Ok(());
185        }
186        if !status.is_success() {
187            let status_code = status.as_u16();
188            let message = response.text().await.unwrap_or_default();
189            warn!(
190                status = status_code,
191                message = message,
192                "ClickUp API error response"
193            );
194            return Err(Error::from_status(status_code, message));
195        }
196        Ok(())
197    }
198
199    /// Handle response and map errors.
200    async fn handle_response<T: serde::de::DeserializeOwned>(
201        &self,
202        response: reqwest::Response,
203    ) -> Result<T> {
204        let status = response.status();
205
206        if !status.is_success() {
207            let status_code = status.as_u16();
208            let message = response.text().await.unwrap_or_default();
209            warn!(
210                status = status_code,
211                message = message,
212                "ClickUp API error response"
213            );
214            return Err(Error::from_status(status_code, message));
215        }
216
217        response
218            .json()
219            .await
220            .map_err(|e| Error::InvalidData(format!("Failed to parse response: {}", e)))
221    }
222
223    /// Resolve a unified state name ("open"/"closed") to the actual ClickUp status name
224    /// by fetching the list's configured statuses.
225    /// If the state doesn't match a known type, it's passed as-is (exact status name).
226    async fn resolve_status(&self, state: &str) -> Result<String> {
227        let status_type = match state {
228            "closed" => "closed",
229            "open" | "opened" => "open",
230            _ => return Ok(state.to_string()),
231        };
232
233        let url = format!("{}/list/{}", self.base_url, self.list_id);
234        let list_info: ClickUpListInfo = self.get(&url).await?;
235
236        list_info
237            .statuses
238            .iter()
239            .find(|s| s.status_type.as_deref() == Some(status_type))
240            .map(|s| s.status.clone())
241            .ok_or_else(|| {
242                Error::InvalidData(format!(
243                    "No status with type '{}' found in list {}",
244                    status_type, self.list_id
245                ))
246            })
247    }
248
249    /// Resolve a task key to its raw ClickUp task ID.
250    /// For `CU-{id}` keys, strips the prefix.
251    /// For custom IDs (e.g., `DEV-42`), returns as-is (ClickUp dependency API accepts custom IDs).
252    fn resolve_task_id(&self, key: &str) -> Result<String> {
253        if let Some(raw_id) = key.strip_prefix("CU-") {
254            Ok(raw_id.to_string())
255        } else {
256            Ok(key.to_string())
257        }
258    }
259
260    /// Resolve a task key to its native ClickUp task ID by fetching the task if needed.
261    /// For `CU-{id}` keys, strips the prefix (fast path).
262    /// For custom IDs (e.g., `DEV-42`), fetches the task to get the native `.id`.
263    /// Use this when the native ID is required in URL path segments.
264    async fn resolve_to_native_id(&self, key: &str) -> Result<String> {
265        if let Some(raw_id) = key.strip_prefix("CU-") {
266            Ok(raw_id.to_string())
267        } else {
268            let url = self.task_url(key)?;
269            let task: ClickUpTask = self.get(&url).await?;
270            Ok(task.id)
271        }
272    }
273
274    /// Build the URL for accessing a task by key.
275    /// For `CU-{id}` keys, uses the raw task ID directly.
276    /// For custom IDs (e.g., `DEV-42`), appends `?custom_task_ids=true&team_id=` params.
277    fn task_url(&self, key: &str) -> Result<String> {
278        if let Some(raw_id) = key.strip_prefix("CU-") {
279            Ok(format!("{}/task/{}", self.base_url, raw_id))
280        } else {
281            // Custom task ID — requires team_id
282            let team_id = self.team_id.as_ref().ok_or_else(|| {
283                Error::Config(format!(
284                    "team_id is required to resolve custom task ID '{}'. \
285                     Run: devboy config set clickup.team_id <team_id>",
286                    key
287                ))
288            })?;
289            Ok(format!(
290                "{}/task/{}?custom_task_ids=true&team_id={}",
291                self.base_url, key, team_id
292            ))
293        }
294    }
295}
296
297// =============================================================================
298// Mapping functions: ClickUp types -> Unified types
299// =============================================================================
300
301fn map_user(cu_user: Option<&ClickUpUser>) -> Option<User> {
302    cu_user.map(|u| User {
303        id: u.id.to_string(),
304        username: u.username.clone(),
305        name: Some(u.username.clone()),
306        email: u.email.clone(),
307        avatar_url: u.profile_picture.clone(),
308    })
309}
310
311fn map_user_required(cu_user: Option<&ClickUpUser>) -> User {
312    map_user(cu_user).unwrap_or_else(|| User {
313        id: "unknown".to_string(),
314        username: "unknown".to_string(),
315        name: Some("Unknown".to_string()),
316        ..Default::default()
317    })
318}
319
320fn map_tags(tags: &[crate::types::ClickUpTag]) -> Vec<String> {
321    tags.iter().map(|t| t.name.clone()).collect()
322}
323
324fn map_priority(priority: Option<&ClickUpPriority>) -> Option<String> {
325    priority.map(|p| match p.id.as_str() {
326        "1" => "urgent".to_string(),
327        "2" => "high".to_string(),
328        "3" => "normal".to_string(),
329        "4" => "low".to_string(),
330        _ => p.priority.to_lowercase(),
331    })
332}
333
334fn map_state(task: &ClickUpTask) -> String {
335    match task.status.status_type.as_deref() {
336        Some("closed") => "closed".to_string(),
337        _ => "open".to_string(),
338    }
339}
340
341/// Map a ClickUp status to a semantic category using both the status type field
342/// and name-based heuristics (for custom statuses where the type is always "custom").
343///
344/// Categories: "backlog", "todo", "in_progress", "done", "cancelled"
345fn map_status_category(status_type: Option<&str>, status_name: &str) -> String {
346    // First, use the explicit type from ClickUp
347    match status_type {
348        Some("closed") | Some("done") => return "done".to_string(),
349        // "open" type in ClickUp is the initial/default status — map via name heuristics below
350        // "custom" type covers most user-defined statuses — also use name heuristics
351        _ => {}
352    }
353
354    // Name-based heuristic matching (case-insensitive)
355    let name_lower = status_name.to_lowercase();
356
357    if name_lower.contains("backlog") {
358        "backlog".to_string()
359    } else if name_lower.contains("cancel")
360        || name_lower.contains("archived")
361        || name_lower.contains("rejected")
362    {
363        "cancelled".to_string()
364    } else if name_lower.contains("done")
365        || name_lower.contains("complete")
366        || name_lower.contains("closed")
367        || name_lower.contains("resolved")
368    {
369        "done".to_string()
370    } else if name_lower.contains("progress")
371        || name_lower.contains("doing")
372        || name_lower.contains("active")
373        || name_lower.contains("review")
374    {
375        "in_progress".to_string()
376    } else if name_lower.contains("todo")
377        || name_lower.contains("to do")
378        || name_lower.contains("open")
379        || name_lower.contains("new")
380    {
381        "todo".to_string()
382    } else {
383        // Unknown custom status — default based on type
384        match status_type {
385            Some("open") => "todo".to_string(),
386            _ => "in_progress".to_string(),
387        }
388    }
389}
390
391/// Build the unified issue key for a task.
392/// Uses `custom_id` when available (e.g., `DEV-42`), otherwise `CU-{id}`.
393fn map_task_key(task: &ClickUpTask) -> String {
394    if let Some(custom_id) = &task.custom_id {
395        custom_id.clone()
396    } else {
397        format!("CU-{}", task.id)
398    }
399}
400
401/// Convert ClickUp epoch-millisecond timestamp to ISO 8601 string.
402fn epoch_ms_to_iso8601(epoch_ms: &str) -> Option<String> {
403    let ms: i64 = epoch_ms.parse().ok()?;
404    let secs = ms / 1000;
405    let datetime = time_from_unix(secs);
406    Some(datetime)
407}
408
409/// Convert unix timestamp to ISO 8601 string without external crate.
410fn time_from_unix(secs: i64) -> String {
411    // Days from unix epoch
412    let mut days = secs / 86400;
413    let day_secs = secs.rem_euclid(86400);
414    if secs % 86400 < 0 {
415        days -= 1;
416    }
417
418    let hours = day_secs / 3600;
419    let minutes = (day_secs % 3600) / 60;
420    let seconds = day_secs % 60;
421
422    // Convert days since epoch to year-month-day
423    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
424    let z = days + 719468;
425    let era = if z >= 0 { z } else { z - 146096 } / 146097;
426    let doe = (z - era * 146097) as u32;
427    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
428    let y = yoe as i64 + era * 400;
429    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
430    let mp = (5 * doy + 2) / 153;
431    let d = doy - (153 * mp + 2) / 5 + 1;
432    let m = if mp < 10 { mp + 3 } else { mp - 9 };
433    let y = if m <= 2 { y + 1 } else { y };
434
435    format!(
436        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
437        y, m, d, hours, minutes, seconds
438    )
439}
440
441fn map_timestamp(ts: &Option<String>) -> Option<String> {
442    ts.as_ref().and_then(|s| epoch_ms_to_iso8601(s))
443}
444
445fn map_task(task: &ClickUpTask) -> Issue {
446    // Surface set custom fields keyed by ClickUp's stable field id
447    // (matches `get_custom_fields` output across providers). Display
448    // name rides along inside `CustomFieldValue.name` so consumers
449    // don't lose it. Unset fields (`value: None`) are skipped to
450    // keep the map noise-free.
451    let custom_fields: std::collections::HashMap<String, devboy_core::CustomFieldValue> = task
452        .custom_fields
453        .iter()
454        .filter_map(|cf| {
455            cf.value.as_ref().map(|v| {
456                (
457                    cf.id.clone(),
458                    devboy_core::CustomFieldValue {
459                        name: cf.name.clone(),
460                        value: v.clone(),
461                    },
462                )
463            })
464        })
465        .collect();
466    Issue {
467        custom_fields,
468        key: map_task_key(task),
469        title: task.name.clone(),
470        description: task
471            .text_content
472            .clone()
473            .or_else(|| task.description.clone()),
474        state: map_state(task),
475        source: "clickup".to_string(),
476        priority: map_priority(task.priority.as_ref()),
477        labels: map_tags(&task.tags),
478        author: map_user(task.creator.as_ref()),
479        assignees: task
480            .assignees
481            .iter()
482            .map(|u| map_user_required(Some(u)))
483            .collect(),
484        url: Some(task.url.clone()),
485        created_at: map_timestamp(&task.date_created),
486        updated_at: map_timestamp(&task.date_updated),
487        attachments_count: if task.attachments.is_empty() {
488            None
489        } else {
490            Some(task.attachments.len() as u32)
491        },
492        parent: task.parent.as_ref().map(|id| format!("CU-{id}")),
493        subtasks: task
494            .subtasks
495            .as_deref()
496            .unwrap_or_default()
497            .iter()
498            .map(map_task)
499            .collect(),
500    }
501}
502
503fn map_comment(cu_comment: &ClickUpComment) -> Comment {
504    Comment {
505        id: cu_comment.id.clone(),
506        body: cu_comment.comment_text.clone(),
507        author: map_user(cu_comment.user.as_ref()),
508        created_at: map_timestamp(&cu_comment.date),
509        updated_at: None,
510        position: None,
511    }
512}
513
514/// Map a ClickUp attachment payload to the provider-agnostic [`AssetMeta`].
515fn map_clickup_attachment(raw: &ClickUpAttachment) -> AssetMeta {
516    let filename = raw
517        .title
518        .clone()
519        .or_else(|| {
520            raw.url
521                .as_deref()
522                .map(devboy_core::asset::filename_from_url)
523        })
524        .unwrap_or_else(|| format!("attachment-{}", raw.id));
525
526    let size = match raw.size.as_ref() {
527        Some(serde_json::Value::Number(n)) => n.as_u64(),
528        Some(serde_json::Value::String(s)) => s.parse::<u64>().ok(),
529        _ => None,
530    };
531
532    let created_at = raw.date.as_deref().and_then(epoch_ms_to_iso8601);
533
534    let author = raw.user.as_ref().map(|u| u.username.clone());
535
536    AssetMeta {
537        id: raw.id.clone(),
538        filename,
539        mime_type: raw.mimetype.clone(),
540        size,
541        url: raw.url.clone(),
542        created_at,
543        author,
544        cached: false,
545        local_path: None,
546        checksum_sha256: None,
547        analysis: None,
548    }
549}
550
551/// Sorting key for priority (lower = more urgent).
552/// urgent=1, high=2, normal=3, low=4, None=5
553fn priority_sort_key(priority: Option<&str>) -> u8 {
554    match priority {
555        Some("urgent") => 1,
556        Some("high") => 2,
557        Some("normal") => 3,
558        Some("low") => 4,
559        _ => 5,
560    }
561}
562
563/// Map a unified priority string to a ClickUp priority number.
564fn priority_to_clickup(priority: &str) -> Option<u8> {
565    match priority {
566        "urgent" => Some(1),
567        "high" => Some(2),
568        "normal" => Some(3),
569        "low" => Some(4),
570        _ => None,
571    }
572}
573
574/// Map ClickUp dependencies (serde_json::Value) to (blocked_by, blocks) IssueLink vectors.
575///
576/// ClickUp dependency JSON shape (observed):
577/// ```json
578/// { "task_id": "abc", "depends_on": "xyz", "type": 1, ... }
579/// ```
580/// - If `depends_on` == this task's ID → `task_id` depends on this task, so this task **blocks** `task_id`
581/// - If `dependency_of` == this task's ID → this task depends on `task_id`, so this task is **blocked by** `task_id`
582fn map_dependencies(
583    deps: &[serde_json::Value],
584    this_task_id: &str,
585) -> (Vec<IssueLink>, Vec<IssueLink>) {
586    let mut blocked_by = Vec::new();
587    let mut blocks = Vec::new();
588
589    for dep in deps {
590        let task_id = dep
591            .get("task_id")
592            .and_then(|v| v.as_str())
593            .unwrap_or_default();
594        let depends_on = dep
595            .get("depends_on")
596            .and_then(|v| v.as_str())
597            .unwrap_or_default();
598        let dependency_of = dep
599            .get("dependency_of")
600            .and_then(|v| v.as_str())
601            .unwrap_or_default();
602
603        let other_id = if !task_id.is_empty() {
604            task_id
605        } else {
606            continue;
607        };
608
609        let other_issue = Issue {
610            key: format!("CU-{other_id}"),
611            source: "clickup".to_string(),
612            ..Default::default()
613        };
614
615        if depends_on == this_task_id {
616            // task_id depends on this task → this task blocks task_id
617            blocks.push(IssueLink {
618                issue: other_issue,
619                link_type: "blocks".to_string(),
620            });
621        } else if dependency_of == this_task_id {
622            // task_id is dependency of this task → this task is blocked by task_id
623            blocked_by.push(IssueLink {
624                issue: other_issue,
625                link_type: "blocked_by".to_string(),
626            });
627        } else {
628            // Fallback: try to infer from "type" field
629            // ClickUp type 1 = waiting on, type 0 = blocking
630            let dep_type = dep.get("type").and_then(|v| v.as_u64());
631            match dep_type {
632                Some(1) => {
633                    blocked_by.push(IssueLink {
634                        issue: other_issue,
635                        link_type: "blocked_by".to_string(),
636                    });
637                }
638                Some(0) => {
639                    blocks.push(IssueLink {
640                        issue: other_issue,
641                        link_type: "blocks".to_string(),
642                    });
643                }
644                _ => {
645                    // Unknown direction, add as blocked_by by default
646                    blocked_by.push(IssueLink {
647                        issue: other_issue,
648                        link_type: "blocked_by".to_string(),
649                    });
650                }
651            }
652        }
653    }
654
655    (blocked_by, blocks)
656}
657
658/// Map ClickUp linked tasks to IssueLinks, preserving dependency semantics when available.
659fn map_linked_tasks(links: &[ClickUpLinkedTask]) -> Vec<IssueLink> {
660    links
661        .iter()
662        .map(|link| {
663            let link_type = match link.link_type.as_deref() {
664                Some("blocked_by") => "blocked_by",
665                Some("blocking") => "blocks",
666                _ => "relates_to",
667            }
668            .to_string();
669
670            IssueLink {
671                issue: Issue {
672                    key: format!("CU-{}", link.task_id),
673                    source: "clickup".to_string(),
674                    ..Default::default()
675                },
676                link_type,
677            }
678        })
679        .collect()
680}
681
682// =============================================================================
683// Trait implementations
684// =============================================================================
685
686#[async_trait]
687impl IssueProvider for ClickUpClient {
688    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
689        let limit = filter.limit.unwrap_or(20) as usize;
690        if limit == 0 {
691            return Ok(vec![].into());
692        }
693        let offset = filter.offset.unwrap_or(0) as usize;
694
695        // Calculate which pages we need to fetch
696        let start_page = offset / PAGE_SIZE as usize;
697        let end_page = (offset + limit).saturating_sub(1) / PAGE_SIZE as usize;
698
699        // Build base query params (without page).
700        // Values are properly URL-encoded by reqwest's .query() method.
701        let mut base_params: Vec<(&str, String)> = vec![];
702
703        let include_closed = matches!(filter.state.as_deref(), Some("closed") | Some("all"))
704            || matches!(
705                filter.state_category.as_deref(),
706                Some("done") | Some("cancelled")
707            );
708        if include_closed {
709            base_params.push(("include_closed", "true".to_string()));
710        }
711
712        base_params.push(("subtasks", "true".to_string()));
713
714        if let Some(assignee) = &filter.assignee {
715            // ClickUp API expects numeric user IDs for assignee filtering,
716            // but IssueFilter.assignee is documented as a username.
717            // Pass through as-is — it will work if the caller provides a user ID.
718            warn!(
719                assignee = assignee.as_str(),
720                "ClickUp assignee filter expects numeric user IDs, not usernames"
721            );
722            base_params.push(("assignees[]", assignee.clone()));
723        }
724
725        if let Some(tags) = &filter.labels {
726            for tag in tags {
727                base_params.push(("tags[]", tag.clone()));
728            }
729        }
730
731        // Track whether client-side sorting is needed for unsupported fields
732        let mut client_side_sort: Option<String> = None;
733
734        if let Some(order_by) = &filter.sort_by {
735            match order_by.as_str() {
736                "created_at" | "created" => {
737                    base_params.push(("order_by", "created".to_string()));
738                }
739                "updated_at" | "updated" => {
740                    base_params.push(("order_by", "updated".to_string()));
741                }
742                other => {
743                    // Unsupported by ClickUp API — will sort client-side after fetch
744                    client_side_sort = Some(other.to_string());
745                    warn!(
746                        sort_by = other,
747                        "ClickUp API does not support sorting by '{}', applying client-side sort",
748                        other
749                    );
750                }
751            }
752        }
753
754        let sort_order_is_asc = filter.sort_order.as_deref().is_some_and(|o| o == "asc");
755
756        if sort_order_is_asc && client_side_sort.is_none() {
757            base_params.push(("reverse", "true".to_string()));
758        }
759
760        // Fetch all needed pages
761        let base_url = format!("{}/list/{}/task", self.base_url, self.list_id);
762        let mut all_tasks: Vec<ClickUpTask> = Vec::new();
763
764        for page in start_page..=end_page {
765            let mut params = base_params.clone();
766            params.push(("page", page.to_string()));
767
768            let param_refs: Vec<(&str, &str)> =
769                params.iter().map(|(k, v)| (*k, v.as_str())).collect();
770            let response: ClickUpTaskList = self.get_with_query(&base_url, &param_refs).await?;
771            let page_len = response.tasks.len();
772            all_tasks.extend(response.tasks);
773
774            // Stop if this page has fewer than PAGE_SIZE items (no more data)
775            if page_len < PAGE_SIZE as usize {
776                break;
777            }
778        }
779
780        // Filter by stateCategory if provided (semantic status filtering).
781        // This must happen on raw ClickUp tasks before mapping, since the actual
782        // status name (e.g., "Backlog", "In Progress") is lost during map_task().
783        if let Some(ref state_category) = filter.state_category {
784            let statuses = self.get_statuses().await?;
785            let matching_status_names: Vec<String> = statuses
786                .items
787                .iter()
788                .filter(|s| s.category == *state_category)
789                .map(|s| s.name.to_lowercase())
790                .collect();
791
792            all_tasks.retain(|t| matching_status_names.contains(&t.status.status.to_lowercase()));
793        }
794
795        let mut issues: Vec<Issue> = all_tasks.iter().map(map_task).collect();
796
797        // Filter by state client-side if needed
798        if let Some(state) = &filter.state {
799            match state.as_str() {
800                "opened" | "open" => {
801                    issues.retain(|i| i.state == "open");
802                }
803                "closed" => {
804                    issues.retain(|i| i.state == "closed");
805                }
806                _ => {} // "all" — no filter
807            }
808        }
809
810        // Labels AND operator: ClickUp API uses OR by default. For AND, post-filter.
811        if filter.labels_operator.as_deref() == Some("and")
812            && let Some(ref required_labels) = filter.labels
813        {
814            let required: Vec<String> = required_labels.iter().map(|l| l.to_lowercase()).collect();
815            issues.retain(|issue| {
816                let issue_labels: Vec<String> =
817                    issue.labels.iter().map(|l| l.to_lowercase()).collect();
818                required.iter().all(|r| issue_labels.contains(r))
819            });
820        }
821
822        // Client-side search filtering (ClickUp API has no search endpoint for tasks)
823        if let Some(ref query) = filter.search {
824            let q = query.to_lowercase();
825            issues.retain(|issue| {
826                issue.title.to_lowercase().contains(&q)
827                    || issue
828                        .description
829                        .as_ref()
830                        .is_some_and(|d| d.to_lowercase().contains(&q))
831                    || issue.key.to_lowercase().contains(&q)
832            });
833        }
834
835        // Client-side sorting for fields unsupported by ClickUp API
836        if let Some(ref sort_field) = client_side_sort {
837            match sort_field.as_str() {
838                "priority" => {
839                    issues.sort_by(|a, b| {
840                        let pa = priority_sort_key(a.priority.as_deref());
841                        let pb = priority_sort_key(b.priority.as_deref());
842                        if sort_order_is_asc {
843                            pa.cmp(&pb)
844                        } else {
845                            pb.cmp(&pa)
846                        }
847                    });
848                }
849                "title" => {
850                    issues.sort_by(|a, b| {
851                        let cmp = a.title.to_lowercase().cmp(&b.title.to_lowercase());
852                        if sort_order_is_asc {
853                            cmp
854                        } else {
855                            cmp.reverse()
856                        }
857                    });
858                }
859                _ => {
860                    // Unknown sort field — leave as-is (API default order)
861                }
862            }
863        }
864
865        // Apply offset within first page and limit
866        let offset_in_first_page = offset % PAGE_SIZE as usize;
867        if offset_in_first_page < issues.len() {
868            issues = issues.split_off(offset_in_first_page);
869        } else {
870            issues.clear();
871        }
872
873        issues.truncate(limit);
874
875        // Build sort info metadata
876        let sort_info = SortInfo {
877            sort_by: filter.sort_by.clone(),
878            sort_order: if sort_order_is_asc {
879                SortOrder::Asc
880            } else {
881                SortOrder::Desc
882            },
883            available_sorts: vec![
884                "created_at".into(),
885                "updated_at".into(),
886                "priority".into(),
887                "title".into(),
888            ],
889        };
890
891        Ok(ProviderResult::new(issues).with_sort_info(sort_info))
892    }
893
894    async fn get_issue(&self, key: &str) -> Result<Issue> {
895        let base_url = self.task_url(key)?;
896        let separator = if base_url.contains('?') { "&" } else { "?" };
897        let url = format!("{}{}include_subtasks=true", base_url, separator);
898        let task: ClickUpTask = self.get(&url).await?;
899        Ok(map_task(&task))
900    }
901
902    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
903        let url = format!("{}/list/{}/task", self.base_url, self.list_id);
904
905        let priority = input.priority.as_deref().and_then(priority_to_clickup);
906
907        let tags = if input.labels.is_empty() {
908            None
909        } else {
910            Some(input.labels)
911        };
912
913        // Resolve parent key to native ClickUp task ID if provided.
914        // Fast-path: if the key is already a CU-{id} key, the native ID is known.
915        let parent = match input.parent {
916            Some(ref parent_key) => {
917                if let Some(stripped) = parent_key.strip_prefix("CU-") {
918                    Some(stripped.to_string())
919                } else {
920                    let parent_url = self.task_url(parent_key)?;
921                    let parent_task: ClickUpTask = self.get(&parent_url).await?;
922                    Some(parent_task.id)
923                }
924            }
925            None => None,
926        };
927
928        let (description, markdown_content) = if input.markdown {
929            (None, input.description)
930        } else {
931            (input.description, None)
932        };
933
934        let request = CreateTaskRequest {
935            name: input.title,
936            description,
937            markdown_content,
938            parent,
939            status: None,
940            priority,
941            tags,
942            assignees: None, // ClickUp expects user IDs, not usernames
943        };
944
945        let task: ClickUpTask = self.post(&url, &request).await?;
946        let task_id = task.id.clone();
947
948        // ClickUp generates custom_id asynchronously after task creation.
949        // Retry GET until custom_id is available (matching DevBoy backend pattern).
950        if task.custom_id.is_none() {
951            for attempt in 1..=3u64 {
952                tokio::time::sleep(std::time::Duration::from_millis(300 * attempt)).await;
953                let fetch_url = format!("{}/task/{}", self.base_url, task_id);
954                if let Ok(fetched) = self.get::<ClickUpTask>(&fetch_url).await
955                    && fetched.custom_id.is_some()
956                {
957                    debug!(
958                        task_id = task_id,
959                        custom_id = ?fetched.custom_id,
960                        attempt = attempt,
961                        "Got custom_id after retry"
962                    );
963                    return Ok(map_task(&fetched));
964                }
965            }
966            warn!(
967                task_id = task_id,
968                "custom_id not available after 3 retries, using POST response"
969            );
970        }
971
972        Ok(map_task(&task))
973    }
974
975    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
976        let url = self.task_url(key)?;
977
978        let status = match input.state {
979            Some(s) => Some(self.resolve_status(&s).await?),
980            None => None,
981        };
982
983        let priority = input.priority.as_deref().and_then(priority_to_clickup);
984
985        let (description, markdown_content) = if input.markdown {
986            (None, input.description)
987        } else {
988            (input.description, None)
989        };
990
991        // Resolve parent key to native ClickUp task ID if provided.
992        // "none" or "" → detach from parent (convert subtask → standalone task).
993        // ClickUp API accepts {"parent": "none"} to remove parent (undocumented but verified).
994        let parent = match input.parent_id {
995            Some(ref parent_key) if parent_key == "none" || parent_key.is_empty() => {
996                Some("none".to_string())
997            }
998            Some(ref parent_key) => {
999                if let Some(stripped) = parent_key.strip_prefix("CU-") {
1000                    Some(stripped.to_string())
1001                } else {
1002                    let parent_url = self.task_url(parent_key)?;
1003                    let parent_task: ClickUpTask = self.get(&parent_url).await?;
1004                    Some(parent_task.id)
1005                }
1006            }
1007            None => None,
1008        };
1009
1010        let request = UpdateTaskRequest {
1011            name: input.title,
1012            description,
1013            markdown_content,
1014            status,
1015            priority,
1016            parent,
1017            tags: None, // Tags updated via separate API below
1018        };
1019
1020        let task: ClickUpTask = self.put(&url, &request).await?;
1021
1022        // Update tags via ClickUp Tag API (PUT /task ignores tags field).
1023        // POST /task/{id}/tag/{name} to add, DELETE /task/{id}/tag/{name} to remove.
1024        if let Some(ref new_labels) = input.labels {
1025            let current_tags: Vec<String> = task.tags.iter().map(|t| t.name.clone()).collect();
1026            let new_tags: Vec<String> = new_labels.iter().map(|l| l.to_lowercase()).collect();
1027
1028            // Remove tags not in new list
1029            for tag in &current_tags {
1030                if !new_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1031                    let tag_url =
1032                        format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1033                    if let Err(e) = self.delete(&tag_url).await {
1034                        warn!(tag = tag, error = %e, "Failed to remove tag");
1035                    }
1036                }
1037            }
1038
1039            // Add tags not in current list
1040            for tag in &new_tags {
1041                if !current_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
1042                    let tag_url =
1043                        format!("{}/task/{}/tag/{}", self.base_url, task.id, encode_tag(tag));
1044                    let resp = self
1045                        .request(reqwest::Method::POST, &tag_url)
1046                        .send()
1047                        .await
1048                        .map_err(|e| Error::Http(e.to_string()))?;
1049                    if !resp.status().is_success() {
1050                        warn!(
1051                            tag = tag,
1052                            status = resp.status().as_u16(),
1053                            "Failed to add tag"
1054                        );
1055                    }
1056                }
1057            }
1058        }
1059
1060        // Re-fetch after PUT because ClickUp can return stale status in the PUT response (#117),
1061        // but do not fail the whole update if the refresh itself is transiently unavailable.
1062        match self.get::<ClickUpTask>(&url).await {
1063            Ok(updated_task) => Ok(map_task(&updated_task)),
1064            Err(e) => {
1065                warn!(
1066                    issue_key = key,
1067                    error = %e,
1068                    "Task updated successfully, but failed to re-fetch fresh state; falling back to PUT response"
1069                );
1070                Ok(map_task(&task))
1071            }
1072        }
1073    }
1074
1075    async fn set_custom_fields(&self, issue_key: &str, fields: &[serde_json::Value]) -> Result<()> {
1076        let task_id = self.resolve_to_native_id(issue_key).await?;
1077        for field in fields {
1078            let field_id = field["id"].as_str().unwrap_or_default();
1079            if field_id.is_empty() {
1080                continue;
1081            }
1082            let url = format!("{}/task/{}/field/{}", self.base_url, task_id, field_id);
1083            let body = serde_json::json!({ "value": field["value"] });
1084            let resp = self
1085                .request(reqwest::Method::POST, &url)
1086                .json(&body)
1087                .send()
1088                .await
1089                .map_err(|e| Error::Http(e.to_string()))?;
1090            if !resp.status().is_success() {
1091                let status = resp.status().as_u16();
1092                let msg = resp.text().await.unwrap_or_default();
1093                warn!(
1094                    field_id = field_id,
1095                    status = status,
1096                    "Failed to set custom field: {}",
1097                    msg
1098                );
1099            }
1100        }
1101        Ok(())
1102    }
1103
1104    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
1105        let base_url = self.task_url(issue_key)?;
1106        // Append /comment — handle both raw URL and URL with query params
1107        let url = if base_url.contains('?') {
1108            let (path, query) = base_url.split_once('?').unwrap();
1109            format!("{}/comment?{}", path, query)
1110        } else {
1111            format!("{}/comment", base_url)
1112        };
1113        let response: ClickUpCommentList = self.get(&url).await?;
1114        Ok(response
1115            .comments
1116            .iter()
1117            .map(map_comment)
1118            .collect::<Vec<_>>()
1119            .into())
1120    }
1121
1122    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
1123        let base_url = self.task_url(issue_key)?;
1124        let url = if base_url.contains('?') {
1125            let (path, query) = base_url.split_once('?').unwrap();
1126            format!("{}/comment?{}", path, query)
1127        } else {
1128            format!("{}/comment", base_url)
1129        };
1130        let request = CreateCommentRequest {
1131            comment_text: body.to_string(),
1132        };
1133
1134        // ClickUp POST returns minimal response (id + date), not full comment
1135        let response: CreateCommentResponse = self.post(&url, &request).await?;
1136        Ok(Comment {
1137            id: response.id,
1138            body: body.to_string(),
1139            author: None,
1140            created_at: map_timestamp(&response.date),
1141            updated_at: None,
1142            position: None,
1143        })
1144    }
1145
1146    async fn upload_attachment(
1147        &self,
1148        issue_key: &str,
1149        filename: &str,
1150        data: &[u8],
1151    ) -> Result<String> {
1152        let task_id = self.resolve_to_native_id(issue_key).await?;
1153        let url = format!("{}/task/{}/attachment", self.base_url, task_id);
1154
1155        let part = reqwest::multipart::Part::bytes(data.to_vec())
1156            .file_name(filename.to_string())
1157            .mime_str("application/octet-stream")
1158            .map_err(|e| Error::Http(format!("Failed to create multipart: {}", e)))?;
1159
1160        let form = reqwest::multipart::Form::new().part("attachment", part);
1161
1162        let response = self
1163            .client
1164            .post(&url)
1165            .header("Authorization", self.token.expose_secret())
1166            .multipart(form)
1167            .send()
1168            .await
1169            .map_err(|e| Error::Http(e.to_string()))?;
1170
1171        let status = response.status();
1172        if !status.is_success() {
1173            let message = response.text().await.unwrap_or_default();
1174            return Err(Error::from_status(status.as_u16(), message));
1175        }
1176
1177        // ClickUp returns: { "attachment": { "url": "..." } } or similar
1178        let body: serde_json::Value = response.json().await.map_err(|e| {
1179            Error::InvalidData(format!("Failed to parse attachment response: {}", e))
1180        })?;
1181
1182        // Extract URL from response
1183        let download_url = body
1184            .pointer("/url")
1185            .or_else(|| body.pointer("/attachment/url"))
1186            .and_then(|v| v.as_str())
1187            .unwrap_or("")
1188            .to_string();
1189
1190        Ok(download_url)
1191    }
1192
1193    async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
1194        let url = self.task_url(issue_key)?;
1195        let task: ClickUpTask = self.get(&url).await?;
1196        Ok(task
1197            .attachments
1198            .iter()
1199            .map(map_clickup_attachment)
1200            .collect())
1201    }
1202
1203    async fn download_attachment(&self, issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1204        // ClickUp does not expose an "attachment by id" endpoint: the
1205        // download URL lives on the task payload. Fetch the task, look up
1206        // the attachment, and download from its URL using our authenticated
1207        // client.
1208        let url = self.task_url(issue_key)?;
1209        let task: ClickUpTask = self.get(&url).await?;
1210        let attachment = task
1211            .attachments
1212            .iter()
1213            .find(|a| a.id == asset_id)
1214            .ok_or_else(|| {
1215                Error::NotFound(format!(
1216                    "attachment '{asset_id}' not found on task {issue_key}",
1217                ))
1218            })?;
1219        let download_url = attachment.url.as_deref().ok_or_else(|| {
1220            Error::InvalidData(format!(
1221                "attachment '{asset_id}' on task {issue_key} has no URL",
1222            ))
1223        })?;
1224
1225        let response = self
1226            .client
1227            .get(download_url)
1228            .header("Authorization", self.token.expose_secret())
1229            .send()
1230            .await
1231            .map_err(|e| Error::Http(e.to_string()))?;
1232
1233        let status = response.status();
1234        if !status.is_success() {
1235            let message = response.text().await.unwrap_or_default();
1236            return Err(Error::from_status(status.as_u16(), message));
1237        }
1238
1239        let bytes = response
1240            .bytes()
1241            .await
1242            .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
1243        Ok(bytes.to_vec())
1244    }
1245
1246    fn asset_capabilities(&self) -> AssetCapabilities {
1247        // ClickUp supports upload / download / list on issue (task) bodies.
1248        // There is no public delete attachment endpoint, so `delete` stays
1249        // false for every context.
1250        AssetCapabilities {
1251            issue: ContextCapabilities {
1252                upload: true,
1253                download: true,
1254                delete: false,
1255                list: true,
1256                max_file_size: None,
1257                allowed_types: Vec::new(),
1258            },
1259            ..Default::default()
1260        }
1261    }
1262
1263    async fn get_statuses(&self) -> Result<ProviderResult<IssueStatus>> {
1264        let url = format!("{}/list/{}", self.base_url, self.list_id);
1265        let list_info: ClickUpListInfo = self.get(&url).await?;
1266
1267        let statuses: Vec<IssueStatus> = list_info
1268            .statuses
1269            .iter()
1270            .enumerate()
1271            .map(|(idx, s)| {
1272                let category = map_status_category(s.status_type.as_deref(), &s.status);
1273                IssueStatus {
1274                    id: s.status.clone(),
1275                    name: s.status.clone(),
1276                    category,
1277                    color: s.color.clone(),
1278                    order: s.orderindex.or(Some(idx as u32)),
1279                }
1280            })
1281            .collect();
1282
1283        Ok(statuses.into())
1284    }
1285
1286    async fn link_issues(&self, source_key: &str, target_key: &str, link_type: &str) -> Result<()> {
1287        match link_type {
1288            "subtask" => {
1289                // source becomes subtask of target → set parent on source task
1290                let source_url = self.task_url(source_key)?;
1291                let target_native_id = self.resolve_to_native_id(target_key).await?;
1292                let body = serde_json::json!({ "parent": target_native_id });
1293                let _: ClickUpTask = self.put(&source_url, &body).await?;
1294            }
1295            "blocks" => {
1296                // source blocks target → target depends_on source
1297                let source_id = self.resolve_task_id(source_key)?;
1298                let target_id = self.resolve_task_id(target_key)?;
1299                let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1300                let body = serde_json::json!({ "depends_on": source_id });
1301                let _: serde_json::Value = self.post(&url, &body).await?;
1302            }
1303            "blocked_by" => {
1304                // source is blocked by target → source depends_on target
1305                let source_id = self.resolve_task_id(source_key)?;
1306                let target_id = self.resolve_task_id(target_key)?;
1307                let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1308                let body = serde_json::json!({ "depends_on": target_id });
1309                let _: serde_json::Value = self.post(&url, &body).await?;
1310            }
1311            _ => {
1312                // Link tasks (bidirectional, non-dependency relationship)
1313                let source_id = self.resolve_to_native_id(source_key).await?;
1314                let target_id = self.resolve_to_native_id(target_key).await?;
1315                let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1316                let body = serde_json::json!({});
1317                let _: serde_json::Value = self.post(&url, &body).await?;
1318            }
1319        }
1320
1321        Ok(())
1322    }
1323
1324    async fn unlink_issues(
1325        &self,
1326        source_key: &str,
1327        target_key: &str,
1328        link_type: &str,
1329    ) -> Result<()> {
1330        match link_type {
1331            "subtask" => {
1332                // Detach source from parent (convert subtask → standalone task).
1333                // Use native task ID + "parent": "none" (undocumented but verified working).
1334                let source_id = self.resolve_to_native_id(source_key).await?;
1335                let url = format!("{}/task/{}", self.base_url, source_id);
1336                let body = serde_json::json!({ "parent": "none" });
1337                let _: ClickUpTask = self.put(&url, &body).await?;
1338            }
1339            "blocks" => {
1340                // Remove: source blocks target → target depends_on source
1341                let source_id = self.resolve_to_native_id(source_key).await?;
1342                let target_id = self.resolve_to_native_id(target_key).await?;
1343                let url = format!("{}/task/{}/dependency", self.base_url, target_id);
1344                self.delete_with_query(&url, &[("depends_on", &source_id)])
1345                    .await?;
1346            }
1347            "blocked_by" => {
1348                // Remove: source is blocked by target → source depends_on target
1349                let source_id = self.resolve_to_native_id(source_key).await?;
1350                let target_id = self.resolve_to_native_id(target_key).await?;
1351                let url = format!("{}/task/{}/dependency", self.base_url, source_id);
1352                self.delete_with_query(&url, &[("depends_on", &target_id)])
1353                    .await?;
1354            }
1355            _ => {
1356                // Remove bidirectional link
1357                let source_id = self.resolve_to_native_id(source_key).await?;
1358                let target_id = self.resolve_to_native_id(target_key).await?;
1359                let url = format!("{}/task/{}/link/{}", self.base_url, source_id, target_id);
1360                self.delete(&url).await?;
1361            }
1362        }
1363
1364        Ok(())
1365    }
1366
1367    async fn get_issue_relations(&self, issue_key: &str) -> Result<IssueRelations> {
1368        let url = self.task_url(issue_key)?;
1369        let task: ClickUpTask = self
1370            .get_with_query(
1371                &url,
1372                &[("include_subtasks", "true"), ("include_closed", "true")],
1373            )
1374            .await?;
1375
1376        let mut relations = IssueRelations::default();
1377
1378        // Parent: fetch parent task for full Issue data
1379        if let Some(ref parent_id) = task.parent {
1380            let parent_url = format!("{}/task/{}", self.base_url, parent_id);
1381            match self.get::<ClickUpTask>(&parent_url).await {
1382                Ok(parent_task) => {
1383                    relations.parent = Some(map_task(&parent_task));
1384                }
1385                Err(e) => {
1386                    tracing::warn!("Failed to fetch parent task {}: {}", parent_id, e);
1387                    // Still include a minimal parent reference
1388                    relations.parent = Some(Issue {
1389                        key: format!("CU-{parent_id}"),
1390                        source: "clickup".to_string(),
1391                        ..Default::default()
1392                    });
1393                }
1394            }
1395        }
1396
1397        // Subtasks
1398        if let Some(ref subtasks) = task.subtasks {
1399            relations.subtasks = subtasks.iter().map(map_task).collect();
1400        }
1401
1402        // Dependencies → blocked_by / blocks
1403        if let Some(ref deps) = task.dependencies {
1404            let (blocked_by, blocks) = map_dependencies(deps, &task.id);
1405            relations.blocked_by = blocked_by;
1406            relations.blocks = blocks;
1407        }
1408
1409        // Linked tasks → related_to
1410        if let Some(ref linked) = task.linked_tasks {
1411            relations.related_to = map_linked_tasks(linked);
1412        }
1413
1414        Ok(relations)
1415    }
1416
1417    fn provider_name(&self) -> &'static str {
1418        "clickup"
1419    }
1420}
1421
1422#[async_trait]
1423impl MergeRequestProvider for ClickUpClient {
1424    fn provider_name(&self) -> &'static str {
1425        "clickup"
1426    }
1427}
1428
1429#[async_trait]
1430impl PipelineProvider for ClickUpClient {
1431    fn provider_name(&self) -> &'static str {
1432        "clickup"
1433    }
1434}
1435
1436#[async_trait]
1437impl Provider for ClickUpClient {
1438    async fn get_current_user(&self) -> Result<User> {
1439        // ClickUp v2 API does not have a /user/me endpoint.
1440        // Verify the token by fetching the first page of tasks with a minimal request.
1441        let url = format!(
1442            "{}/list/{}/task?page=0&subtasks=false",
1443            self.base_url, self.list_id
1444        );
1445        let _: ClickUpTaskList = self.get(&url).await?;
1446
1447        // Token is valid — return a synthetic user
1448        Ok(User {
1449            id: "clickup".to_string(),
1450            username: "clickup-user".to_string(),
1451            name: Some("ClickUp User".to_string()),
1452            ..Default::default()
1453        })
1454    }
1455}
1456
1457// =============================================================================
1458// Tests
1459// =============================================================================
1460
1461#[cfg(test)]
1462mod tests {
1463    use super::*;
1464    use crate::types::{ClickUpStatus, ClickUpTag};
1465    use devboy_core::{CreateCommentInput, MrFilter};
1466
1467    fn token(s: &str) -> SecretString {
1468        SecretString::from(s.to_string())
1469    }
1470
1471    #[test]
1472    fn test_epoch_ms_to_iso8601() {
1473        // 2024-01-01T00:00:00Z = 1704067200000 ms
1474        assert_eq!(
1475            epoch_ms_to_iso8601("1704067200000"),
1476            Some("2024-01-01T00:00:00Z".to_string())
1477        );
1478
1479        // 2024-01-02T00:00:00Z = 1704153600000 ms
1480        assert_eq!(
1481            epoch_ms_to_iso8601("1704153600000"),
1482            Some("2024-01-02T00:00:00Z".to_string())
1483        );
1484
1485        // 2024-01-15T10:00:00Z = 1705312800000 ms
1486        assert_eq!(
1487            epoch_ms_to_iso8601("1705312800000"),
1488            Some("2024-01-15T10:00:00Z".to_string())
1489        );
1490
1491        // Invalid input
1492        assert_eq!(epoch_ms_to_iso8601("not_a_number"), None);
1493    }
1494
1495    #[test]
1496    fn test_task_url_cu_prefix() {
1497        let client =
1498            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1499        let url = client.task_url("CU-abc123").unwrap();
1500        assert_eq!(url, "https://api.clickup.com/api/v2/task/abc123");
1501    }
1502
1503    #[test]
1504    fn test_task_url_custom_id_with_team() {
1505        let client =
1506            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"))
1507                .with_team_id("9876");
1508        let url = client.task_url("DEV-42").unwrap();
1509        assert_eq!(
1510            url,
1511            "https://api.clickup.com/api/v2/task/DEV-42?custom_task_ids=true&team_id=9876"
1512        );
1513    }
1514
1515    #[test]
1516    fn test_task_url_custom_id_without_team() {
1517        let client =
1518            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1519        let result = client.task_url("DEV-42");
1520        assert!(result.is_err());
1521    }
1522
1523    /// `task.custom_fields` populates `issue.custom_fields` keyed
1524    /// by ClickUp's stable field **id** (matches the homogeneous
1525    /// shape `JOIN`able with `get_custom_fields` across providers).
1526    /// Display name rides along inside `CustomFieldValue.name`.
1527    /// Unset fields (`value == None`) are filtered.
1528    #[test]
1529    fn test_map_task_surfaces_custom_field_values() {
1530        let task = ClickUpTask {
1531            id: "abc123".to_string(),
1532            custom_id: None,
1533            name: "T".to_string(),
1534            description: None,
1535            text_content: None,
1536            status: ClickUpStatus {
1537                status: "open".to_string(),
1538                status_type: Some("open".to_string()),
1539            },
1540            priority: None,
1541            tags: vec![],
1542            assignees: vec![],
1543            creator: None,
1544            url: "https://app.clickup.com/t/abc123".to_string(),
1545            date_created: None,
1546            date_updated: None,
1547            parent: None,
1548            subtasks: None,
1549            dependencies: None,
1550            linked_tasks: None,
1551            attachments: Vec::new(),
1552            custom_fields: vec![
1553                crate::types::ClickUpCustomField {
1554                    id: "cf-1".to_string(),
1555                    name: Some("Severity".to_string()),
1556                    field_type: Some("drop_down".to_string()),
1557                    value: Some(serde_json::json!("High")),
1558                },
1559                crate::types::ClickUpCustomField {
1560                    id: "cf-2".to_string(),
1561                    name: Some("Sprint".to_string()),
1562                    field_type: Some("text".to_string()),
1563                    value: None, // unset → must be skipped
1564                },
1565                crate::types::ClickUpCustomField {
1566                    id: "cf-3".to_string(),
1567                    name: None, // anonymous → falls back to id
1568                    field_type: Some("number".to_string()),
1569                    value: Some(serde_json::json!(42)),
1570                },
1571            ],
1572        };
1573
1574        let issue = map_task(&task);
1575        // Keyed by id; display name rides along in
1576        // `CustomFieldValue.name`.
1577        let severity = issue.custom_fields.get("cf-1").expect("cf-1 present");
1578        assert_eq!(severity.name.as_deref(), Some("Severity"));
1579        assert_eq!(severity.value, serde_json::json!("High"));
1580        // Unset value (`cf-2`) is filtered out entirely.
1581        assert!(!issue.custom_fields.contains_key("cf-2"));
1582        // Anonymous field (no name) keeps `name: None`.
1583        let anon = issue.custom_fields.get("cf-3").expect("cf-3 present");
1584        assert!(anon.name.is_none());
1585        assert_eq!(anon.value, serde_json::json!(42));
1586    }
1587
1588    #[test]
1589    fn test_map_task() {
1590        let task = ClickUpTask {
1591            id: "abc123".to_string(),
1592            custom_id: None,
1593            name: "Fix bug".to_string(),
1594            description: Some("Bug description".to_string()),
1595            text_content: Some("Bug text content".to_string()),
1596            status: ClickUpStatus {
1597                status: "open".to_string(),
1598                status_type: Some("open".to_string()),
1599            },
1600            priority: Some(ClickUpPriority {
1601                id: "2".to_string(),
1602                priority: "high".to_string(),
1603                color: None,
1604            }),
1605            tags: vec![ClickUpTag {
1606                name: "bug".to_string(),
1607            }],
1608            assignees: vec![ClickUpUser {
1609                id: 1,
1610                username: "dev1".to_string(),
1611                email: Some("dev1@example.com".to_string()),
1612                profile_picture: None,
1613            }],
1614            creator: Some(ClickUpUser {
1615                id: 2,
1616                username: "creator".to_string(),
1617                email: None,
1618                profile_picture: None,
1619            }),
1620            url: "https://app.clickup.com/t/abc123".to_string(),
1621            date_created: Some("1704067200000".to_string()),
1622            date_updated: Some("1704153600000".to_string()),
1623            parent: None,
1624            subtasks: None,
1625            dependencies: None,
1626            linked_tasks: None,
1627            attachments: Vec::new(),
1628            custom_fields: Vec::new(),
1629        };
1630
1631        let issue = map_task(&task);
1632        assert_eq!(issue.key, "CU-abc123");
1633        assert_eq!(issue.title, "Fix bug");
1634        assert_eq!(issue.description, Some("Bug text content".to_string()));
1635        assert_eq!(issue.state, "open");
1636        assert_eq!(issue.source, "clickup");
1637        assert_eq!(issue.priority, Some("high".to_string()));
1638        assert_eq!(issue.labels, vec!["bug"]);
1639        assert_eq!(issue.assignees.len(), 1);
1640        assert_eq!(issue.assignees[0].username, "dev1");
1641        assert!(issue.author.is_some());
1642        assert_eq!(issue.author.unwrap().username, "creator");
1643        assert_eq!(
1644            issue.url,
1645            Some("https://app.clickup.com/t/abc123".to_string())
1646        );
1647        // Timestamps are now ISO 8601
1648        assert_eq!(issue.created_at, Some("2024-01-01T00:00:00Z".to_string()));
1649        assert_eq!(issue.updated_at, Some("2024-01-02T00:00:00Z".to_string()));
1650    }
1651
1652    #[test]
1653    fn test_map_task_with_custom_id() {
1654        let task = ClickUpTask {
1655            id: "abc123".to_string(),
1656            custom_id: Some("DEV-42".to_string()),
1657            name: "Task with custom ID".to_string(),
1658            description: None,
1659            text_content: None,
1660            status: ClickUpStatus {
1661                status: "open".to_string(),
1662                status_type: Some("open".to_string()),
1663            },
1664            priority: None,
1665            tags: vec![],
1666            assignees: vec![],
1667            creator: None,
1668            url: "https://app.clickup.com/t/abc123".to_string(),
1669            date_created: None,
1670            date_updated: None,
1671            parent: None,
1672            subtasks: None,
1673            dependencies: None,
1674            linked_tasks: None,
1675            attachments: Vec::new(),
1676            custom_fields: Vec::new(),
1677        };
1678
1679        let issue = map_task(&task);
1680        assert_eq!(issue.key, "DEV-42");
1681    }
1682
1683    #[test]
1684    fn test_map_task_closed_status() {
1685        let task = ClickUpTask {
1686            id: "abc123".to_string(),
1687            custom_id: None,
1688            name: "Closed task".to_string(),
1689            description: None,
1690            text_content: None,
1691            status: ClickUpStatus {
1692                status: "done".to_string(),
1693                status_type: Some("closed".to_string()),
1694            },
1695            priority: None,
1696            tags: vec![],
1697            assignees: vec![],
1698            creator: None,
1699            url: "https://app.clickup.com/t/abc123".to_string(),
1700            date_created: None,
1701            date_updated: None,
1702            parent: None,
1703            subtasks: None,
1704            dependencies: None,
1705            linked_tasks: None,
1706            attachments: Vec::new(),
1707            custom_fields: Vec::new(),
1708        };
1709
1710        let issue = map_task(&task);
1711        assert_eq!(issue.state, "closed");
1712    }
1713
1714    #[test]
1715    fn test_map_priority_all_levels() {
1716        let make_priority = |id: &str, name: &str| ClickUpPriority {
1717            id: id.to_string(),
1718            priority: name.to_string(),
1719            color: None,
1720        };
1721
1722        assert_eq!(
1723            map_priority(Some(&make_priority("1", "urgent"))),
1724            Some("urgent".to_string())
1725        );
1726        assert_eq!(
1727            map_priority(Some(&make_priority("2", "high"))),
1728            Some("high".to_string())
1729        );
1730        assert_eq!(
1731            map_priority(Some(&make_priority("3", "normal"))),
1732            Some("normal".to_string())
1733        );
1734        assert_eq!(
1735            map_priority(Some(&make_priority("4", "low"))),
1736            Some("low".to_string())
1737        );
1738        assert_eq!(map_priority(None), None);
1739    }
1740
1741    #[test]
1742    fn test_map_user() {
1743        let cu_user = ClickUpUser {
1744            id: 123,
1745            username: "testuser".to_string(),
1746            email: Some("test@example.com".to_string()),
1747            profile_picture: Some("https://example.com/avatar.png".to_string()),
1748        };
1749
1750        let user = map_user(Some(&cu_user)).unwrap();
1751        assert_eq!(user.id, "123");
1752        assert_eq!(user.username, "testuser");
1753        assert_eq!(user.name, Some("testuser".to_string()));
1754        assert_eq!(user.email, Some("test@example.com".to_string()));
1755        assert_eq!(
1756            user.avatar_url,
1757            Some("https://example.com/avatar.png".to_string())
1758        );
1759    }
1760
1761    #[test]
1762    fn test_map_user_none() {
1763        assert!(map_user(None).is_none());
1764    }
1765
1766    #[test]
1767    fn test_map_user_required_with_user() {
1768        let cu_user = ClickUpUser {
1769            id: 1,
1770            username: "user1".to_string(),
1771            email: None,
1772            profile_picture: None,
1773        };
1774        let user = map_user_required(Some(&cu_user));
1775        assert_eq!(user.username, "user1");
1776    }
1777
1778    #[test]
1779    fn test_map_user_required_without_user() {
1780        let user = map_user_required(None);
1781        assert_eq!(user.id, "unknown");
1782        assert_eq!(user.username, "unknown");
1783    }
1784
1785    #[test]
1786    fn test_map_clickup_attachment_all_fields() {
1787        let raw = ClickUpAttachment {
1788            id: "att-1".into(),
1789            title: Some("report.log".into()),
1790            url: Some("https://attachments.clickup.com/abc/report.log".into()),
1791            size: Some(serde_json::json!("2048")),
1792            extension: Some("log".into()),
1793            mimetype: Some("text/plain".into()),
1794            date: Some("1704067200000".into()),
1795            user: Some(ClickUpUser {
1796                id: 7,
1797                username: "uploader".into(),
1798                email: None,
1799                profile_picture: None,
1800            }),
1801        };
1802        let meta = map_clickup_attachment(&raw);
1803        assert_eq!(meta.id, "att-1");
1804        assert_eq!(meta.filename, "report.log");
1805        assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
1806        assert_eq!(meta.size, Some(2048));
1807        assert_eq!(
1808            meta.url.as_deref(),
1809            Some("https://attachments.clickup.com/abc/report.log")
1810        );
1811        assert_eq!(meta.author.as_deref(), Some("uploader"));
1812        assert_eq!(meta.created_at, Some("2024-01-01T00:00:00Z".to_string()));
1813        assert!(!meta.cached);
1814    }
1815
1816    #[test]
1817    fn test_map_clickup_attachment_minimal_falls_back_to_url() {
1818        let raw = ClickUpAttachment {
1819            id: "att-2".into(),
1820            title: None,
1821            url: Some("https://cdn/a/b/screen.png?token=x".into()),
1822            size: Some(serde_json::json!(4096)),
1823            extension: None,
1824            mimetype: None,
1825            date: None,
1826            user: None,
1827        };
1828        let meta = map_clickup_attachment(&raw);
1829        // Filename falls back to the last path segment, query stripped.
1830        assert_eq!(meta.filename, "screen.png");
1831        assert_eq!(meta.size, Some(4096));
1832        assert!(meta.created_at.is_none());
1833        assert!(meta.author.is_none());
1834    }
1835
1836    #[test]
1837    fn test_map_clickup_attachment_missing_everything() {
1838        let raw = ClickUpAttachment {
1839            id: "att-3".into(),
1840            title: None,
1841            url: None,
1842            size: None,
1843            extension: None,
1844            mimetype: None,
1845            date: None,
1846            user: None,
1847        };
1848        let meta = map_clickup_attachment(&raw);
1849        // When title and URL are missing the fallback uses the attachment id.
1850        assert_eq!(meta.filename, "attachment-att-3");
1851        assert!(meta.url.is_none());
1852        assert!(meta.size.is_none());
1853    }
1854
1855    #[test]
1856    fn test_clickup_asset_capabilities() {
1857        let client =
1858            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1859        let caps = client.asset_capabilities();
1860        assert!(caps.issue.upload);
1861        assert!(caps.issue.download);
1862        assert!(caps.issue.list);
1863        assert!(!caps.issue.delete, "ClickUp has no delete attachment API");
1864        assert!(
1865            !caps.merge_request.upload,
1866            "ClickUp does not track merge requests",
1867        );
1868    }
1869
1870    #[test]
1871    fn test_map_comment() {
1872        let cu_comment = ClickUpComment {
1873            id: "42".to_string(),
1874            comment_text: "Nice work!".to_string(),
1875            user: Some(ClickUpUser {
1876                id: 1,
1877                username: "reviewer".to_string(),
1878                email: None,
1879                profile_picture: None,
1880            }),
1881            date: Some("1705312800000".to_string()),
1882        };
1883
1884        let comment = map_comment(&cu_comment);
1885        assert_eq!(comment.id, "42");
1886        assert_eq!(comment.body, "Nice work!");
1887        assert!(comment.author.is_some());
1888        assert_eq!(comment.author.unwrap().username, "reviewer");
1889        // Timestamp is now ISO 8601
1890        assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
1891        assert!(comment.position.is_none());
1892    }
1893
1894    #[test]
1895    fn test_map_tags() {
1896        let tags = vec![
1897            ClickUpTag {
1898                name: "bug".to_string(),
1899            },
1900            ClickUpTag {
1901                name: "feature".to_string(),
1902            },
1903        ];
1904        let result = map_tags(&tags);
1905        assert_eq!(result, vec!["bug", "feature"]);
1906    }
1907
1908    #[test]
1909    fn test_map_tags_empty() {
1910        let result = map_tags(&[]);
1911        assert!(result.is_empty());
1912    }
1913
1914    #[test]
1915    fn test_priority_to_clickup() {
1916        assert_eq!(priority_to_clickup("urgent"), Some(1));
1917        assert_eq!(priority_to_clickup("high"), Some(2));
1918        assert_eq!(priority_to_clickup("normal"), Some(3));
1919        assert_eq!(priority_to_clickup("low"), Some(4));
1920        assert_eq!(priority_to_clickup("unknown"), None);
1921    }
1922
1923    #[test]
1924    fn test_api_url() {
1925        let client =
1926            ClickUpClient::with_base_url("https://api.clickup.com/api/v2", "12345", token("token"));
1927        assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
1928        assert_eq!(client.list_id, "12345");
1929    }
1930
1931    #[test]
1932    fn test_api_url_strips_trailing_slash() {
1933        let client = ClickUpClient::with_base_url(
1934            "https://api.clickup.com/api/v2/",
1935            "12345",
1936            token("token"),
1937        );
1938        assert_eq!(client.base_url, "https://api.clickup.com/api/v2");
1939    }
1940
1941    #[test]
1942    fn test_with_team_id() {
1943        let client = ClickUpClient::new("12345", token("token")).with_team_id("9876");
1944        assert_eq!(client.team_id, Some("9876".to_string()));
1945    }
1946
1947    #[test]
1948    fn test_provider_name() {
1949        let client = ClickUpClient::new("12345", token("token"));
1950        assert_eq!(IssueProvider::provider_name(&client), "clickup");
1951        assert_eq!(MergeRequestProvider::provider_name(&client), "clickup");
1952    }
1953
1954    #[test]
1955    fn test_map_task_description_fallback() {
1956        let task = ClickUpTask {
1957            id: "abc".to_string(),
1958            custom_id: None,
1959            name: "Task".to_string(),
1960            description: Some("HTML description".to_string()),
1961            text_content: None,
1962            status: ClickUpStatus {
1963                status: "open".to_string(),
1964                status_type: Some("open".to_string()),
1965            },
1966            priority: None,
1967            tags: vec![],
1968            assignees: vec![],
1969            creator: None,
1970            url: "https://app.clickup.com/t/abc".to_string(),
1971            date_created: None,
1972            date_updated: None,
1973            parent: None,
1974            subtasks: None,
1975            dependencies: None,
1976            linked_tasks: None,
1977            attachments: Vec::new(),
1978            custom_fields: Vec::new(),
1979        };
1980
1981        let issue = map_task(&task);
1982        assert_eq!(issue.description, Some("HTML description".to_string()));
1983    }
1984
1985    #[test]
1986    fn test_map_state_custom_type() {
1987        let task = ClickUpTask {
1988            id: "abc".to_string(),
1989            custom_id: None,
1990            name: "Task".to_string(),
1991            description: None,
1992            text_content: None,
1993            status: ClickUpStatus {
1994                status: "in progress".to_string(),
1995                status_type: Some("custom".to_string()),
1996            },
1997            priority: None,
1998            tags: vec![],
1999            assignees: vec![],
2000            creator: None,
2001            url: "https://app.clickup.com/t/abc".to_string(),
2002            date_created: None,
2003            date_updated: None,
2004            parent: None,
2005            subtasks: None,
2006            dependencies: None,
2007            linked_tasks: None,
2008            attachments: Vec::new(),
2009            custom_fields: Vec::new(),
2010        };
2011
2012        let issue = map_task(&task);
2013        assert_eq!(issue.state, "open");
2014    }
2015
2016    #[test]
2017    fn test_map_task_with_parent() {
2018        let task = ClickUpTask {
2019            id: "child1".to_string(),
2020            custom_id: Some("DEV-100".to_string()),
2021            name: "Child task".to_string(),
2022            description: None,
2023            text_content: None,
2024            status: ClickUpStatus {
2025                status: "open".to_string(),
2026                status_type: Some("open".to_string()),
2027            },
2028            priority: None,
2029            tags: vec![],
2030            assignees: vec![],
2031            creator: None,
2032            url: "https://app.clickup.com/t/child1".to_string(),
2033            date_created: None,
2034            date_updated: None,
2035            parent: Some("parent123".to_string()),
2036            subtasks: None,
2037            dependencies: None,
2038            linked_tasks: None,
2039            attachments: Vec::new(),
2040            custom_fields: Vec::new(),
2041        };
2042
2043        let issue = map_task(&task);
2044        assert_eq!(issue.parent, Some("CU-parent123".to_string()));
2045        assert!(issue.subtasks.is_empty());
2046    }
2047
2048    #[test]
2049    fn test_map_task_with_subtasks() {
2050        let subtask = ClickUpTask {
2051            id: "sub1".to_string(),
2052            custom_id: Some("DEV-201".to_string()),
2053            name: "Subtask 1".to_string(),
2054            description: None,
2055            text_content: None,
2056            status: ClickUpStatus {
2057                status: "in progress".to_string(),
2058                status_type: Some("custom".to_string()),
2059            },
2060            priority: None,
2061            tags: vec![],
2062            assignees: vec![],
2063            creator: None,
2064            url: "https://app.clickup.com/t/sub1".to_string(),
2065            date_created: None,
2066            date_updated: None,
2067            parent: Some("epic1".to_string()),
2068            subtasks: None,
2069            dependencies: None,
2070            linked_tasks: None,
2071            attachments: Vec::new(),
2072            custom_fields: Vec::new(),
2073        };
2074
2075        let task = ClickUpTask {
2076            id: "epic1".to_string(),
2077            custom_id: Some("DEV-200".to_string()),
2078            name: "Epic task".to_string(),
2079            description: None,
2080            text_content: None,
2081            status: ClickUpStatus {
2082                status: "open".to_string(),
2083                status_type: Some("open".to_string()),
2084            },
2085            priority: None,
2086            tags: vec![ClickUpTag {
2087                name: "epic".to_string(),
2088            }],
2089            assignees: vec![],
2090            creator: None,
2091            url: "https://app.clickup.com/t/epic1".to_string(),
2092            date_created: None,
2093            date_updated: None,
2094            parent: None,
2095            subtasks: Some(vec![subtask]),
2096            dependencies: None,
2097            linked_tasks: None,
2098            attachments: Vec::new(),
2099            custom_fields: Vec::new(),
2100        };
2101
2102        let issue = map_task(&task);
2103        assert_eq!(issue.key, "DEV-200");
2104        assert!(issue.parent.is_none());
2105        assert_eq!(issue.subtasks.len(), 1);
2106        assert_eq!(issue.subtasks[0].key, "DEV-201");
2107        assert_eq!(issue.subtasks[0].title, "Subtask 1");
2108        assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2109    }
2110
2111    #[test]
2112    fn test_map_task_no_parent_no_subtasks() {
2113        let task = ClickUpTask {
2114            id: "standalone".to_string(),
2115            custom_id: None,
2116            name: "Standalone task".to_string(),
2117            description: None,
2118            text_content: None,
2119            status: ClickUpStatus {
2120                status: "open".to_string(),
2121                status_type: Some("open".to_string()),
2122            },
2123            priority: None,
2124            tags: vec![],
2125            assignees: vec![],
2126            creator: None,
2127            url: "https://app.clickup.com/t/standalone".to_string(),
2128            date_created: None,
2129            date_updated: None,
2130            parent: None,
2131            subtasks: None,
2132            dependencies: None,
2133            linked_tasks: None,
2134            attachments: Vec::new(),
2135            custom_fields: Vec::new(),
2136        };
2137
2138        let issue = map_task(&task);
2139        assert!(issue.parent.is_none());
2140        assert!(issue.subtasks.is_empty());
2141    }
2142
2143    #[test]
2144    fn test_deserialize_task_with_parent_and_subtasks() {
2145        let json = serde_json::json!({
2146            "id": "epic1",
2147            "custom_id": "DEV-300",
2148            "name": "Epic with subtasks",
2149            "status": {"status": "open", "type": "open"},
2150            "tags": [{"name": "epic"}],
2151            "assignees": [],
2152            "url": "https://app.clickup.com/t/epic1",
2153            "parent": null,
2154            "subtasks": [
2155                {
2156                    "id": "sub1",
2157                    "custom_id": "DEV-301",
2158                    "name": "Subtask A",
2159                    "status": {"status": "open", "type": "open"},
2160                    "tags": [],
2161                    "assignees": [],
2162                    "url": "https://app.clickup.com/t/sub1",
2163                    "parent": "epic1"
2164                },
2165                {
2166                    "id": "sub2",
2167                    "name": "Subtask B",
2168                    "status": {"status": "closed", "type": "closed"},
2169                    "tags": [],
2170                    "assignees": [],
2171                    "url": "https://app.clickup.com/t/sub2",
2172                    "parent": "epic1"
2173                }
2174            ]
2175        });
2176
2177        let task: ClickUpTask = serde_json::from_value(json).unwrap();
2178        assert!(task.parent.is_none());
2179        assert_eq!(task.subtasks.as_ref().unwrap().len(), 2);
2180        assert_eq!(
2181            task.subtasks.as_ref().unwrap()[0].custom_id,
2182            Some("DEV-301".to_string())
2183        );
2184        assert_eq!(
2185            task.subtasks.as_ref().unwrap()[1].parent,
2186            Some("epic1".to_string())
2187        );
2188
2189        let issue = map_task(&task);
2190        assert_eq!(issue.subtasks.len(), 2);
2191        assert_eq!(issue.subtasks[0].key, "DEV-301");
2192        assert_eq!(issue.subtasks[1].key, "CU-sub2");
2193        assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
2194    }
2195
2196    #[test]
2197    fn test_deserialize_task_without_subtasks_field() {
2198        // ClickUp API may omit subtasks field entirely
2199        let json = serde_json::json!({
2200            "id": "task1",
2201            "name": "Simple task",
2202            "status": {"status": "open", "type": "open"},
2203            "tags": [],
2204            "assignees": [],
2205            "url": "https://app.clickup.com/t/task1"
2206        });
2207
2208        let task: ClickUpTask = serde_json::from_value(json).unwrap();
2209        assert!(task.parent.is_none());
2210        assert!(task.subtasks.is_none());
2211
2212        let issue = map_task(&task);
2213        assert!(issue.parent.is_none());
2214        assert!(issue.subtasks.is_empty());
2215    }
2216
2217    #[test]
2218    fn test_map_status_category_name_heuristics() {
2219        // Explicit types
2220        assert_eq!(map_status_category(Some("closed"), "Done"), "done");
2221        assert_eq!(map_status_category(Some("done"), "Complete"), "done");
2222
2223        // Custom statuses — name-based mapping
2224        assert_eq!(map_status_category(Some("custom"), "Backlog"), "backlog");
2225        assert_eq!(
2226            map_status_category(Some("custom"), "Product Backlog"),
2227            "backlog"
2228        );
2229        assert_eq!(map_status_category(Some("custom"), "To Do"), "todo");
2230        assert_eq!(map_status_category(Some("custom"), "New"), "todo");
2231        assert_eq!(
2232            map_status_category(Some("custom"), "In Progress"),
2233            "in_progress"
2234        );
2235        assert_eq!(
2236            map_status_category(Some("custom"), "Code Review"),
2237            "in_progress"
2238        );
2239        assert_eq!(map_status_category(Some("custom"), "Doing"), "in_progress");
2240        assert_eq!(map_status_category(Some("custom"), "Active"), "in_progress");
2241        assert_eq!(map_status_category(Some("custom"), "Done"), "done");
2242        assert_eq!(map_status_category(Some("custom"), "Completed"), "done");
2243        assert_eq!(map_status_category(Some("custom"), "Resolved"), "done");
2244        assert_eq!(
2245            map_status_category(Some("custom"), "Cancelled"),
2246            "cancelled"
2247        );
2248        assert_eq!(map_status_category(Some("custom"), "Archived"), "cancelled");
2249        assert_eq!(map_status_category(Some("custom"), "Rejected"), "cancelled");
2250
2251        // Open type — defaults to "todo"
2252        assert_eq!(map_status_category(Some("open"), "Open"), "todo");
2253
2254        // Unknown custom status — defaults to "in_progress"
2255        assert_eq!(
2256            map_status_category(Some("custom"), "Some Custom Status"),
2257            "in_progress"
2258        );
2259    }
2260
2261    #[test]
2262    fn test_priority_sort_key() {
2263        assert_eq!(priority_sort_key(Some("urgent")), 1);
2264        assert_eq!(priority_sort_key(Some("high")), 2);
2265        assert_eq!(priority_sort_key(Some("normal")), 3);
2266        assert_eq!(priority_sort_key(Some("low")), 4);
2267        assert_eq!(priority_sort_key(None), 5);
2268    }
2269
2270    // =========================================================================
2271    // Integration tests with httpmock
2272    // =========================================================================
2273
2274    mod integration {
2275        use super::*;
2276        use httpmock::prelude::*;
2277
2278        fn create_test_client(server: &MockServer) -> ClickUpClient {
2279            ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2280        }
2281
2282        fn create_test_client_with_team(server: &MockServer) -> ClickUpClient {
2283            ClickUpClient::with_base_url(server.base_url(), "12345", token("pk_test_token"))
2284                .with_team_id("9876")
2285        }
2286
2287        fn sample_task_json() -> serde_json::Value {
2288            serde_json::json!({
2289                "id": "abc123",
2290                "name": "Test Task",
2291                "description": "<p>Task description</p>",
2292                "text_content": "Task description",
2293                "status": {
2294                    "status": "open",
2295                    "type": "open"
2296                },
2297                "priority": {
2298                    "id": "2",
2299                    "priority": "high",
2300                    "color": "#ffcc00"
2301                },
2302                "tags": [{"name": "bug"}],
2303                "assignees": [{"id": 1, "username": "dev1"}],
2304                "creator": {"id": 2, "username": "creator"},
2305                "url": "https://app.clickup.com/t/abc123",
2306                "date_created": "1704067200000",
2307                "date_updated": "1704153600000"
2308            })
2309        }
2310
2311        fn sample_closed_task_json() -> serde_json::Value {
2312            serde_json::json!({
2313                "id": "def456",
2314                "name": "Closed Task",
2315                "status": {
2316                    "status": "done",
2317                    "type": "closed"
2318                },
2319                "tags": [],
2320                "assignees": [],
2321                "url": "https://app.clickup.com/t/def456",
2322                "date_created": "1704067200000",
2323                "date_updated": "1704153600000"
2324            })
2325        }
2326
2327        fn sample_task_with_custom_id_json() -> serde_json::Value {
2328            serde_json::json!({
2329                "id": "abc123",
2330                "custom_id": "DEV-42",
2331                "name": "Task with custom ID",
2332                "status": {
2333                    "status": "open",
2334                    "type": "open"
2335                },
2336                "tags": [],
2337                "assignees": [],
2338                "url": "https://app.clickup.com/t/abc123",
2339                "date_created": "1704067200000",
2340                "date_updated": "1704153600000"
2341            })
2342        }
2343
2344        #[tokio::test]
2345        async fn test_get_issues() {
2346            let server = MockServer::start();
2347
2348            server.mock(|when, then| {
2349                when.method(GET)
2350                    .path("/list/12345/task")
2351                    .header("Authorization", "pk_test_token");
2352                then.status(200)
2353                    .json_body(serde_json::json!({"tasks": [sample_task_json()]}));
2354            });
2355
2356            let client = create_test_client(&server);
2357            let issues = client
2358                .get_issues(IssueFilter::default())
2359                .await
2360                .unwrap()
2361                .items;
2362
2363            assert_eq!(issues.len(), 1);
2364            assert_eq!(issues[0].key, "CU-abc123");
2365            assert_eq!(issues[0].title, "Test Task");
2366            assert_eq!(issues[0].source, "clickup");
2367            assert_eq!(issues[0].priority, Some("high".to_string()));
2368            // Verify ISO 8601 timestamps
2369            assert_eq!(
2370                issues[0].created_at,
2371                Some("2024-01-01T00:00:00Z".to_string())
2372            );
2373        }
2374
2375        #[tokio::test]
2376        async fn test_get_issues_with_filters() {
2377            let server = MockServer::start();
2378
2379            server.mock(|when, then| {
2380                when.method(GET)
2381                    .path("/list/12345/task")
2382                    .query_param("include_closed", "true")
2383                    .query_param("subtasks", "true")
2384                    .query_param("tags[]", "bug");
2385                then.status(200).json_body(
2386                    serde_json::json!({"tasks": [sample_task_json(), sample_closed_task_json()]}),
2387                );
2388            });
2389
2390            let client = create_test_client(&server);
2391            let issues = client
2392                .get_issues(IssueFilter {
2393                    state: Some("all".to_string()),
2394                    labels: Some(vec!["bug".to_string()]),
2395                    ..Default::default()
2396                })
2397                .await
2398                .unwrap()
2399                .items;
2400
2401            assert_eq!(issues.len(), 2);
2402        }
2403
2404        #[tokio::test]
2405        async fn test_get_issues_state_filter_open() {
2406            let server = MockServer::start();
2407
2408            server.mock(|when, then| {
2409                when.method(GET).path("/list/12345/task");
2410                then.status(200).json_body(serde_json::json!({
2411                    "tasks": [sample_task_json(), sample_closed_task_json()]
2412                }));
2413            });
2414
2415            let client = create_test_client(&server);
2416            let issues = client
2417                .get_issues(IssueFilter {
2418                    state: Some("open".to_string()),
2419                    ..Default::default()
2420                })
2421                .await
2422                .unwrap()
2423                .items;
2424
2425            assert_eq!(issues.len(), 1);
2426            assert_eq!(issues[0].state, "open");
2427        }
2428
2429        #[tokio::test]
2430        async fn test_get_issues_state_filter_closed() {
2431            let server = MockServer::start();
2432
2433            server.mock(|when, then| {
2434                when.method(GET)
2435                    .path("/list/12345/task")
2436                    .query_param("include_closed", "true");
2437                then.status(200).json_body(serde_json::json!({
2438                    "tasks": [sample_task_json(), sample_closed_task_json()]
2439                }));
2440            });
2441
2442            let client = create_test_client(&server);
2443            let issues = client
2444                .get_issues(IssueFilter {
2445                    state: Some("closed".to_string()),
2446                    ..Default::default()
2447                })
2448                .await
2449                .unwrap()
2450                .items;
2451
2452            assert_eq!(issues.len(), 1);
2453            assert_eq!(issues[0].state, "closed");
2454        }
2455
2456        #[tokio::test]
2457        async fn test_get_issues_pagination() {
2458            let server = MockServer::start();
2459
2460            let tasks: Vec<serde_json::Value> = (0..5)
2461                .map(|i| {
2462                    serde_json::json!({
2463                        "id": format!("task{}", i),
2464                        "name": format!("Task {}", i),
2465                        "status": {"status": "open", "type": "open"},
2466                        "tags": [],
2467                        "assignees": [],
2468                        "url": format!("https://app.clickup.com/t/task{}", i),
2469                        "date_created": "1704067200000",
2470                        "date_updated": "1704153600000"
2471                    })
2472                })
2473                .collect();
2474
2475            server.mock(|when, then| {
2476                when.method(GET)
2477                    .path("/list/12345/task")
2478                    .query_param("page", "0");
2479                then.status(200)
2480                    .json_body(serde_json::json!({"tasks": tasks}));
2481            });
2482
2483            let client = create_test_client(&server);
2484
2485            let issues = client
2486                .get_issues(IssueFilter {
2487                    limit: Some(2),
2488                    offset: Some(1),
2489                    ..Default::default()
2490                })
2491                .await
2492                .unwrap()
2493                .items;
2494
2495            assert_eq!(issues.len(), 2);
2496            assert_eq!(issues[0].key, "CU-task1");
2497            assert_eq!(issues[1].key, "CU-task2");
2498        }
2499
2500        #[tokio::test]
2501        async fn test_get_issues_limit_zero() {
2502            // No server needed — should return immediately without making API calls
2503            let client = ClickUpClient::new("12345", token("token"));
2504            let issues = client
2505                .get_issues(IssueFilter {
2506                    limit: Some(0),
2507                    ..Default::default()
2508                })
2509                .await
2510                .unwrap()
2511                .items;
2512
2513            assert!(issues.is_empty());
2514        }
2515
2516        #[tokio::test]
2517        async fn test_get_issues_multi_page() {
2518            let server = MockServer::start();
2519
2520            // Page 0: 100 tasks
2521            let page0_tasks: Vec<serde_json::Value> = (0..100)
2522                .map(|i| {
2523                    serde_json::json!({
2524                        "id": format!("task{}", i),
2525                        "name": format!("Task {}", i),
2526                        "status": {"status": "open", "type": "open"},
2527                        "tags": [],
2528                        "assignees": [],
2529                        "url": format!("https://app.clickup.com/t/task{}", i),
2530                        "date_created": "1704067200000",
2531                        "date_updated": "1704153600000"
2532                    })
2533                })
2534                .collect();
2535
2536            // Page 1: 50 tasks
2537            let page1_tasks: Vec<serde_json::Value> = (100..150)
2538                .map(|i| {
2539                    serde_json::json!({
2540                        "id": format!("task{}", i),
2541                        "name": format!("Task {}", i),
2542                        "status": {"status": "open", "type": "open"},
2543                        "tags": [],
2544                        "assignees": [],
2545                        "url": format!("https://app.clickup.com/t/task{}", i),
2546                        "date_created": "1704067200000",
2547                        "date_updated": "1704153600000"
2548                    })
2549                })
2550                .collect();
2551
2552            server.mock(|when, then| {
2553                when.method(GET)
2554                    .path("/list/12345/task")
2555                    .query_param("page", "0");
2556                then.status(200)
2557                    .json_body(serde_json::json!({"tasks": page0_tasks}));
2558            });
2559
2560            server.mock(|when, then| {
2561                when.method(GET)
2562                    .path("/list/12345/task")
2563                    .query_param("page", "1");
2564                then.status(200)
2565                    .json_body(serde_json::json!({"tasks": page1_tasks}));
2566            });
2567
2568            let client = create_test_client(&server);
2569
2570            // Request 120 tasks — should fetch 2 pages
2571            let issues = client
2572                .get_issues(IssueFilter {
2573                    limit: Some(120),
2574                    offset: Some(0),
2575                    ..Default::default()
2576                })
2577                .await
2578                .unwrap()
2579                .items;
2580
2581            assert_eq!(issues.len(), 120);
2582            assert_eq!(issues[0].key, "CU-task0");
2583            assert_eq!(issues[99].key, "CU-task99");
2584            assert_eq!(issues[100].key, "CU-task100");
2585            assert_eq!(issues[119].key, "CU-task119");
2586        }
2587
2588        #[tokio::test]
2589        async fn test_get_issue() {
2590            let server = MockServer::start();
2591
2592            server.mock(|when, then| {
2593                when.method(GET).path("/task/abc123");
2594                then.status(200).json_body(sample_task_json());
2595            });
2596
2597            let client = create_test_client(&server);
2598            let issue = client.get_issue("CU-abc123").await.unwrap();
2599
2600            assert_eq!(issue.key, "CU-abc123");
2601            assert_eq!(issue.title, "Test Task");
2602            assert_eq!(issue.priority, Some("high".to_string()));
2603        }
2604
2605        #[tokio::test]
2606        async fn test_get_issue_by_custom_id() {
2607            let server = MockServer::start();
2608
2609            server.mock(|when, then| {
2610                when.method(GET)
2611                    .path("/task/DEV-42")
2612                    .query_param("custom_task_ids", "true")
2613                    .query_param("team_id", "9876");
2614                then.status(200)
2615                    .json_body(sample_task_with_custom_id_json());
2616            });
2617
2618            let client = create_test_client_with_team(&server);
2619            let issue = client.get_issue("DEV-42").await.unwrap();
2620
2621            assert_eq!(issue.key, "DEV-42");
2622            assert_eq!(issue.title, "Task with custom ID");
2623        }
2624
2625        #[tokio::test]
2626        async fn test_get_issue_custom_id_without_team_fails() {
2627            let client = ClickUpClient::new("12345", token("token"));
2628            let result = client.get_issue("DEV-42").await;
2629            assert!(result.is_err());
2630        }
2631
2632        #[tokio::test]
2633        async fn test_create_issue_with_custom_id_retry() {
2634            let server = MockServer::start();
2635
2636            // POST returns task without custom_id
2637            server.mock(|when, then| {
2638                when.method(POST)
2639                    .path("/list/12345/task")
2640                    .body_includes("\"name\":\"New Task\"");
2641                then.status(200).json_body(sample_task_json());
2642            });
2643
2644            // GET retry returns task with custom_id
2645            let mut task_with_custom_id = sample_task_json();
2646            task_with_custom_id["custom_id"] = serde_json::json!("DEV-100");
2647
2648            server.mock(|when, then| {
2649                when.method(GET).path("/task/abc123");
2650                then.status(200).json_body(task_with_custom_id);
2651            });
2652
2653            let client = create_test_client(&server);
2654            let issue = client
2655                .create_issue(CreateIssueInput {
2656                    title: "New Task".to_string(),
2657                    description: Some("Description".to_string()),
2658                    labels: vec!["bug".to_string()],
2659                    ..Default::default()
2660                })
2661                .await
2662                .unwrap();
2663
2664            // Should use custom_id from retry GET
2665            assert_eq!(issue.key, "DEV-100");
2666        }
2667
2668        #[tokio::test]
2669        async fn test_create_issue_fallback_without_custom_id() {
2670            let server = MockServer::start();
2671
2672            // POST returns task without custom_id
2673            server.mock(|when, then| {
2674                when.method(POST)
2675                    .path("/list/12345/task")
2676                    .body_includes("\"name\":\"New Task\"");
2677                then.status(200).json_body(sample_task_json());
2678            });
2679
2680            // GET retry also returns without custom_id
2681            server.mock(|when, then| {
2682                when.method(GET).path("/task/abc123");
2683                then.status(200).json_body(sample_task_json());
2684            });
2685
2686            let client = create_test_client(&server);
2687            let issue = client
2688                .create_issue(CreateIssueInput {
2689                    title: "New Task".to_string(),
2690                    ..Default::default()
2691                })
2692                .await
2693                .unwrap();
2694
2695            // Fallback to CU-{id}
2696            assert_eq!(issue.key, "CU-abc123");
2697        }
2698
2699        #[tokio::test]
2700        async fn test_create_issue_with_priority() {
2701            let server = MockServer::start();
2702
2703            // Return task with custom_id to skip retry
2704            let mut task = sample_task_json();
2705            task["custom_id"] = serde_json::json!("DEV-101");
2706
2707            server.mock(|when, then| {
2708                when.method(POST)
2709                    .path("/list/12345/task")
2710                    .body_includes("\"priority\":1");
2711                then.status(200).json_body(task);
2712            });
2713
2714            let client = create_test_client(&server);
2715            let result = client
2716                .create_issue(CreateIssueInput {
2717                    title: "Urgent Task".to_string(),
2718                    priority: Some("urgent".to_string()),
2719                    ..Default::default()
2720                })
2721                .await;
2722
2723            assert!(result.is_ok());
2724            assert_eq!(result.unwrap().key, "DEV-101");
2725        }
2726
2727        #[tokio::test]
2728        async fn test_update_issue() {
2729            let server = MockServer::start();
2730
2731            server.mock(|when, then| {
2732                when.method(PUT)
2733                    .path("/task/abc123")
2734                    .body_includes("\"name\":\"Updated Task\"");
2735                then.status(200).json_body(sample_task_json());
2736            });
2737
2738            server.mock(|when, then| {
2739                when.method(GET).path("/task/abc123");
2740                then.status(200).json_body(sample_task_json());
2741            });
2742
2743            let client = create_test_client(&server);
2744            let issue = client
2745                .update_issue(
2746                    "CU-abc123",
2747                    UpdateIssueInput {
2748                        title: Some("Updated Task".to_string()),
2749                        ..Default::default()
2750                    },
2751                )
2752                .await
2753                .unwrap();
2754
2755            assert_eq!(issue.key, "CU-abc123");
2756        }
2757
2758        #[tokio::test]
2759        async fn test_update_issue_by_custom_id() {
2760            let server = MockServer::start();
2761
2762            server.mock(|when, then| {
2763                when.method(PUT)
2764                    .path("/task/DEV-42")
2765                    .query_param("custom_task_ids", "true")
2766                    .query_param("team_id", "9876");
2767                then.status(200)
2768                    .json_body(sample_task_with_custom_id_json());
2769            });
2770
2771            server.mock(|when, then| {
2772                when.method(GET)
2773                    .path("/task/DEV-42")
2774                    .query_param("custom_task_ids", "true")
2775                    .query_param("team_id", "9876");
2776                then.status(200)
2777                    .json_body(sample_task_with_custom_id_json());
2778            });
2779
2780            let client = create_test_client_with_team(&server);
2781            let issue = client
2782                .update_issue(
2783                    "DEV-42",
2784                    UpdateIssueInput {
2785                        title: Some("Updated".to_string()),
2786                        ..Default::default()
2787                    },
2788                )
2789                .await
2790                .unwrap();
2791
2792            assert_eq!(issue.key, "DEV-42");
2793        }
2794
2795        #[tokio::test]
2796        async fn test_update_issue_state_mapping() {
2797            let server = MockServer::start();
2798
2799            // Mock list info endpoint for status resolution
2800            server.mock(|when, then| {
2801                when.method(GET).path("/list/12345");
2802                then.status(200).json_body(serde_json::json!({
2803                    "statuses": [
2804                        {"status": "to do", "type": "open"},
2805                        {"status": "in progress", "type": "custom"},
2806                        {"status": "complete", "type": "closed"}
2807                    ]
2808                }));
2809            });
2810
2811            server.mock(|when, then| {
2812                when.method(PUT)
2813                    .path("/task/abc123")
2814                    .body_includes("\"status\":\"complete\"");
2815                then.status(200).json_body(sample_task_json());
2816            });
2817
2818            server.mock(|when, then| {
2819                when.method(GET).path("/task/abc123");
2820                then.status(200).json_body(sample_task_json());
2821            });
2822
2823            let client = create_test_client(&server);
2824            let result = client
2825                .update_issue(
2826                    "CU-abc123",
2827                    UpdateIssueInput {
2828                        state: Some("closed".to_string()),
2829                        ..Default::default()
2830                    },
2831                )
2832                .await;
2833
2834            assert!(result.is_ok());
2835        }
2836
2837        /// Regression test for #117: PUT response returns stale status,
2838        /// but re-fetched GET response reflects the actual closed state.
2839        #[tokio::test]
2840        async fn test_update_issue_state_refetch_returns_fresh_state() {
2841            let server = MockServer::start();
2842
2843            server.mock(|when, then| {
2844                when.method(GET).path("/list/12345");
2845                then.status(200).json_body(serde_json::json!({
2846                    "statuses": [
2847                        {"status": "to do", "type": "open"},
2848                        {"status": "complete", "type": "closed"}
2849                    ]
2850                }));
2851            });
2852
2853            // PUT returns stale "open" status (ClickUp behavior)
2854            server.mock(|when, then| {
2855                when.method(PUT)
2856                    .path("/task/abc123")
2857                    .body_includes("\"status\":\"complete\"");
2858                then.status(200).json_body(sample_task_json()); // status.type = "open"
2859            });
2860
2861            // GET returns the updated "closed" status
2862            server.mock(|when, then| {
2863                when.method(GET).path("/task/abc123");
2864                then.status(200).json_body(serde_json::json!({
2865                    "id": "abc123",
2866                    "name": "Test Task",
2867                    "status": {
2868                        "status": "complete",
2869                        "type": "closed"
2870                    },
2871                    "tags": [{"name": "bug"}],
2872                    "assignees": [{"id": 1, "username": "dev1"}],
2873                    "url": "https://app.clickup.com/t/abc123",
2874                    "date_created": "1704067200000",
2875                    "date_updated": "1704153600000"
2876                }));
2877            });
2878
2879            let client = create_test_client(&server);
2880            let issue = client
2881                .update_issue(
2882                    "CU-abc123",
2883                    UpdateIssueInput {
2884                        state: Some("closed".to_string()),
2885                        ..Default::default()
2886                    },
2887                )
2888                .await
2889                .unwrap();
2890
2891            assert_eq!(issue.state, "closed");
2892        }
2893
2894        #[tokio::test]
2895        async fn test_update_issue_state_open_mapping() {
2896            let server = MockServer::start();
2897
2898            server.mock(|when, then| {
2899                when.method(GET).path("/list/12345");
2900                then.status(200).json_body(serde_json::json!({
2901                    "statuses": [
2902                        {"status": "to do", "type": "open"},
2903                        {"status": "complete", "type": "closed"}
2904                    ]
2905                }));
2906            });
2907
2908            server.mock(|when, then| {
2909                when.method(PUT)
2910                    .path("/task/abc123")
2911                    .body_includes("\"status\":\"to do\"");
2912                then.status(200).json_body(sample_task_json());
2913            });
2914
2915            server.mock(|when, then| {
2916                when.method(GET).path("/task/abc123");
2917                then.status(200).json_body(sample_task_json());
2918            });
2919
2920            let client = create_test_client(&server);
2921            let result = client
2922                .update_issue(
2923                    "CU-abc123",
2924                    UpdateIssueInput {
2925                        state: Some("open".to_string()),
2926                        ..Default::default()
2927                    },
2928                )
2929                .await;
2930
2931            assert!(result.is_ok());
2932        }
2933
2934        #[tokio::test]
2935        async fn test_update_issue_exact_status_name() {
2936            let server = MockServer::start();
2937
2938            // Exact status name — no list lookup needed
2939            server.mock(|when, then| {
2940                when.method(PUT)
2941                    .path("/task/abc123")
2942                    .body_includes("\"status\":\"in progress\"");
2943                then.status(200).json_body(sample_task_json());
2944            });
2945
2946            server.mock(|when, then| {
2947                when.method(GET).path("/task/abc123");
2948                then.status(200).json_body(sample_task_json());
2949            });
2950
2951            let client = create_test_client(&server);
2952            let result = client
2953                .update_issue(
2954                    "CU-abc123",
2955                    UpdateIssueInput {
2956                        state: Some("in progress".to_string()),
2957                        ..Default::default()
2958                    },
2959                )
2960                .await;
2961
2962            assert!(result.is_ok());
2963        }
2964
2965        #[tokio::test]
2966        async fn test_get_comments() {
2967            let server = MockServer::start();
2968
2969            server.mock(|when, then| {
2970                when.method(GET).path("/task/abc123/comment");
2971                then.status(200).json_body(serde_json::json!({
2972                    "comments": [{
2973                        "id": "1",
2974                        "comment_text": "Looks good!",
2975                        "user": {"id": 1, "username": "reviewer"},
2976                        "date": "1705312800000"
2977                    }]
2978                }));
2979            });
2980
2981            let client = create_test_client(&server);
2982            let comments = client.get_comments("CU-abc123").await.unwrap().items;
2983
2984            assert_eq!(comments.len(), 1);
2985            assert_eq!(comments[0].body, "Looks good!");
2986            assert_eq!(comments[0].author.as_ref().unwrap().username, "reviewer");
2987            // Verify ISO 8601 timestamp
2988            assert_eq!(
2989                comments[0].created_at,
2990                Some("2024-01-15T10:00:00Z".to_string())
2991            );
2992        }
2993
2994        #[tokio::test]
2995        async fn test_add_comment() {
2996            let server = MockServer::start();
2997
2998            // ClickUp POST /comment returns minimal response (id as number, no comment_text)
2999            server.mock(|when, then| {
3000                when.method(POST)
3001                    .path("/task/abc123/comment")
3002                    .body_includes("\"comment_text\":\"My comment\"");
3003                then.status(200).json_body(serde_json::json!({
3004                    "id": 458315,
3005                    "hist_id": "26b2d7f1-test",
3006                    "date": 1705312800000_i64
3007                }));
3008            });
3009
3010            let client = create_test_client(&server);
3011            let comment = IssueProvider::add_comment(&client, "CU-abc123", "My comment")
3012                .await
3013                .unwrap();
3014
3015            assert_eq!(comment.body, "My comment");
3016            assert_eq!(comment.id, "458315");
3017            assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
3018        }
3019
3020        #[tokio::test]
3021        async fn test_handle_response_401() {
3022            let server = MockServer::start();
3023
3024            server.mock(|when, then| {
3025                when.method(GET).path("/list/12345/task");
3026                then.status(401).body("Token invalid");
3027            });
3028
3029            let client = create_test_client(&server);
3030            let result = client.get_issues(IssueFilter::default()).await;
3031
3032            assert!(result.is_err());
3033            let err = result.unwrap_err();
3034            assert!(matches!(err, Error::Unauthorized(_)));
3035        }
3036
3037        #[tokio::test]
3038        async fn test_handle_response_404() {
3039            let server = MockServer::start();
3040
3041            server.mock(|when, then| {
3042                when.method(GET).path("/task/nonexistent");
3043                then.status(404).body("Task not found");
3044            });
3045
3046            let client = create_test_client(&server);
3047            let result = client.get_issue("CU-nonexistent").await;
3048
3049            assert!(result.is_err());
3050            let err = result.unwrap_err();
3051            assert!(matches!(err, Error::NotFound(_)));
3052        }
3053
3054        #[tokio::test]
3055        async fn test_handle_response_500() {
3056            let server = MockServer::start();
3057
3058            server.mock(|when, then| {
3059                when.method(GET).path("/list/12345/task");
3060                then.status(500).body("Internal Server Error");
3061            });
3062
3063            let client = create_test_client(&server);
3064            let result = client.get_issues(IssueFilter::default()).await;
3065
3066            assert!(result.is_err());
3067            let err = result.unwrap_err();
3068            assert!(matches!(err, Error::ServerError { .. }));
3069        }
3070
3071        #[tokio::test]
3072        async fn test_mr_methods_unsupported() {
3073            let client = ClickUpClient::new("12345", token("token"));
3074
3075            let result = client.get_merge_requests(MrFilter::default()).await;
3076            assert!(matches!(
3077                result.unwrap_err(),
3078                Error::ProviderUnsupported { .. }
3079            ));
3080
3081            let result = client.get_merge_request("mr#1").await;
3082            assert!(matches!(
3083                result.unwrap_err(),
3084                Error::ProviderUnsupported { .. }
3085            ));
3086
3087            let result = client.get_discussions("mr#1").await;
3088            assert!(matches!(
3089                result.unwrap_err(),
3090                Error::ProviderUnsupported { .. }
3091            ));
3092
3093            let result = client.get_diffs("mr#1").await;
3094            assert!(matches!(
3095                result.unwrap_err(),
3096                Error::ProviderUnsupported { .. }
3097            ));
3098
3099            let result = MergeRequestProvider::add_comment(
3100                &client,
3101                "mr#1",
3102                CreateCommentInput {
3103                    body: "test".to_string(),
3104                    position: None,
3105                    discussion_id: None,
3106                },
3107            )
3108            .await;
3109            assert!(matches!(
3110                result.unwrap_err(),
3111                Error::ProviderUnsupported { .. }
3112            ));
3113        }
3114
3115        #[tokio::test]
3116        async fn test_get_current_user() {
3117            let server = MockServer::start();
3118
3119            server.mock(|when, then| {
3120                when.method(GET).path("/list/12345/task");
3121                then.status(200).json_body(serde_json::json!({"tasks": []}));
3122            });
3123
3124            let client = create_test_client(&server);
3125            let user = client.get_current_user().await.unwrap();
3126
3127            assert_eq!(user.username, "clickup-user");
3128        }
3129
3130        #[tokio::test]
3131        async fn test_get_current_user_auth_failure() {
3132            let server = MockServer::start();
3133
3134            server.mock(|when, then| {
3135                when.method(GET).path("/list/12345/task");
3136                then.status(401).body("Unauthorized");
3137            });
3138
3139            let client = create_test_client(&server);
3140            let result = client.get_current_user().await;
3141
3142            assert!(result.is_err());
3143            assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
3144        }
3145
3146        #[tokio::test]
3147        async fn test_get_issue_includes_subtasks() {
3148            let server = MockServer::start();
3149
3150            let task_with_subtasks = serde_json::json!({
3151                "id": "epic1",
3152                "custom_id": "DEV-400",
3153                "name": "Epic Task",
3154                "status": {"status": "open", "type": "open"},
3155                "tags": [{"name": "epic"}],
3156                "assignees": [],
3157                "creator": {"id": 1, "username": "author"},
3158                "url": "https://app.clickup.com/t/epic1",
3159                "date_created": "1704067200000",
3160                "date_updated": "1704153600000",
3161                "subtasks": [
3162                    {
3163                        "id": "sub1",
3164                        "custom_id": "DEV-401",
3165                        "name": "Subtask 1",
3166                        "status": {"status": "open", "type": "open"},
3167                        "tags": [],
3168                        "assignees": [],
3169                        "url": "https://app.clickup.com/t/sub1",
3170                        "parent": "epic1"
3171                    },
3172                    {
3173                        "id": "sub2",
3174                        "custom_id": "DEV-402",
3175                        "name": "Subtask 2",
3176                        "status": {"status": "closed", "type": "closed"},
3177                        "tags": [],
3178                        "assignees": [],
3179                        "url": "https://app.clickup.com/t/sub2",
3180                        "parent": "epic1"
3181                    }
3182                ]
3183            });
3184
3185            server.mock(|when, then| {
3186                when.method(GET)
3187                    .path("/task/epic1")
3188                    .query_param("include_subtasks", "true");
3189                then.status(200).json_body(task_with_subtasks);
3190            });
3191
3192            let client = create_test_client(&server);
3193            let issue = client.get_issue("CU-epic1").await.unwrap();
3194
3195            assert_eq!(issue.key, "DEV-400");
3196            assert!(issue.parent.is_none());
3197            assert_eq!(issue.subtasks.len(), 2);
3198            assert_eq!(issue.subtasks[0].key, "DEV-401");
3199            assert_eq!(issue.subtasks[0].title, "Subtask 1");
3200            assert_eq!(issue.subtasks[0].state, "open");
3201            assert_eq!(issue.subtasks[0].parent, Some("CU-epic1".to_string()));
3202            assert_eq!(issue.subtasks[1].key, "DEV-402");
3203            assert_eq!(issue.subtasks[1].state, "closed");
3204        }
3205
3206        #[tokio::test]
3207        async fn test_get_issue_no_subtasks() {
3208            let server = MockServer::start();
3209
3210            let task = sample_task_json();
3211
3212            server.mock(|when, then| {
3213                when.method(GET)
3214                    .path("/task/abc123")
3215                    .query_param("include_subtasks", "true");
3216                then.status(200).json_body(task);
3217            });
3218
3219            let client = create_test_client(&server);
3220            let issue = client.get_issue("CU-abc123").await.unwrap();
3221
3222            assert!(issue.subtasks.is_empty());
3223            assert!(issue.parent.is_none());
3224        }
3225
3226        #[tokio::test]
3227        async fn test_get_issue_custom_id_includes_subtasks() {
3228            let server = MockServer::start();
3229
3230            let task = serde_json::json!({
3231                "id": "task1",
3232                "custom_id": "DEV-500",
3233                "name": "Task via custom ID",
3234                "status": {"status": "open", "type": "open"},
3235                "tags": [],
3236                "assignees": [],
3237                "url": "https://app.clickup.com/t/task1",
3238                "parent": "parent123",
3239                "subtasks": []
3240            });
3241
3242            server.mock(|when, then| {
3243                when.method(GET)
3244                    .path("/task/DEV-500")
3245                    .query_param("custom_task_ids", "true")
3246                    .query_param("team_id", "9876")
3247                    .query_param("include_subtasks", "true");
3248                then.status(200).json_body(task);
3249            });
3250
3251            let client = create_test_client_with_team(&server);
3252            let issue = client.get_issue("DEV-500").await.unwrap();
3253
3254            assert_eq!(issue.key, "DEV-500");
3255            assert_eq!(issue.parent, Some("CU-parent123".to_string()));
3256            assert!(issue.subtasks.is_empty());
3257        }
3258
3259        #[tokio::test]
3260        async fn test_update_issue_with_parent_id() {
3261            let server = MockServer::start();
3262
3263            // Mock: resolve parent task by custom ID
3264            let parent_task = serde_json::json!({
3265                "id": "parent_native_id",
3266                "custom_id": "DEV-600",
3267                "name": "Parent Epic",
3268                "status": {"status": "open", "type": "open"},
3269                "tags": [],
3270                "assignees": [],
3271                "url": "https://app.clickup.com/t/parent_native_id"
3272            });
3273
3274            server.mock(|when, then| {
3275                when.method(GET)
3276                    .path("/task/DEV-600")
3277                    .query_param("custom_task_ids", "true")
3278                    .query_param("team_id", "9876");
3279                then.status(200).json_body(parent_task);
3280            });
3281
3282            // Mock: update task with parent
3283            let updated_task = serde_json::json!({
3284                "id": "child1",
3285                "custom_id": "DEV-601",
3286                "name": "Child Task",
3287                "status": {"status": "open", "type": "open"},
3288                "tags": [],
3289                "assignees": [],
3290                "url": "https://app.clickup.com/t/child1",
3291                "parent": "parent_native_id"
3292            });
3293
3294            server.mock(|when, then| {
3295                when.method(PUT)
3296                    .path("/task/DEV-601")
3297                    .query_param("custom_task_ids", "true")
3298                    .query_param("team_id", "9876")
3299                    .body_includes("\"parent\":\"parent_native_id\"");
3300                then.status(200).json_body(updated_task.clone());
3301            });
3302
3303            server.mock(|when, then| {
3304                when.method(GET)
3305                    .path("/task/DEV-601")
3306                    .query_param("custom_task_ids", "true")
3307                    .query_param("team_id", "9876");
3308                then.status(200).json_body(updated_task);
3309            });
3310
3311            let client = create_test_client_with_team(&server);
3312            let issue = client
3313                .update_issue(
3314                    "DEV-601",
3315                    UpdateIssueInput {
3316                        parent_id: Some("DEV-600".to_string()),
3317                        ..Default::default()
3318                    },
3319                )
3320                .await
3321                .unwrap();
3322
3323            assert_eq!(issue.key, "DEV-601");
3324            assert_eq!(issue.parent, Some("CU-parent_native_id".to_string()));
3325        }
3326
3327        #[tokio::test]
3328        async fn test_create_issue_with_parent() {
3329            let server = MockServer::start();
3330
3331            // Mock: resolve parent task
3332            let parent_task = serde_json::json!({
3333                "id": "parent_id",
3334                "custom_id": "DEV-700",
3335                "name": "Parent",
3336                "status": {"status": "open", "type": "open"},
3337                "tags": [],
3338                "assignees": [],
3339                "url": "https://app.clickup.com/t/parent_id"
3340            });
3341
3342            server.mock(|when, then| {
3343                when.method(GET)
3344                    .path("/task/DEV-700")
3345                    .query_param("custom_task_ids", "true")
3346                    .query_param("team_id", "9876");
3347                then.status(200).json_body(parent_task);
3348            });
3349
3350            // Mock: create task with parent
3351            let created_task = serde_json::json!({
3352                "id": "new_child",
3353                "custom_id": "DEV-701",
3354                "name": "New Subtask",
3355                "status": {"status": "open", "type": "open"},
3356                "tags": [],
3357                "assignees": [],
3358                "url": "https://app.clickup.com/t/new_child",
3359                "parent": "parent_id"
3360            });
3361
3362            server.mock(|when, then| {
3363                when.method(POST)
3364                    .path("/list/12345/task")
3365                    .body_includes("\"parent\":\"parent_id\"");
3366                then.status(200).json_body(created_task);
3367            });
3368
3369            let client = create_test_client_with_team(&server);
3370            let issue = client
3371                .create_issue(CreateIssueInput {
3372                    title: "New Subtask".to_string(),
3373                    parent: Some("DEV-700".to_string()),
3374                    ..Default::default()
3375                })
3376                .await
3377                .unwrap();
3378
3379            assert_eq!(issue.key, "DEV-701");
3380            assert_eq!(issue.parent, Some("CU-parent_id".to_string()));
3381        }
3382
3383        #[tokio::test]
3384        async fn test_get_issues_search_filter() {
3385            let server = MockServer::start_async().await;
3386
3387            server.mock(|when, then| {
3388                when.method(GET).path("/list/12345/task");
3389                then.status(200).json_body(serde_json::json!({
3390                    "tasks": [
3391                        {
3392                            "id": "1", "name": "Fix login bug",
3393                            "description": "Authentication fails",
3394                            "text_content": "Authentication fails",
3395                            "status": {"status": "open", "type": "open"},
3396                            "tags": [], "assignees": [],
3397                            "url": "https://app.clickup.com/t/1"
3398                        },
3399                        {
3400                            "id": "2", "name": "Add dark mode",
3401                            "description": "Theme support",
3402                            "text_content": "Theme support",
3403                            "status": {"status": "open", "type": "open"},
3404                            "tags": [], "assignees": [],
3405                            "url": "https://app.clickup.com/t/2"
3406                        },
3407                        {
3408                            "id": "3", "name": "Update docs",
3409                            "description": "Fix login instructions",
3410                            "text_content": "Fix login instructions",
3411                            "status": {"status": "open", "type": "open"},
3412                            "tags": [], "assignees": [],
3413                            "url": "https://app.clickup.com/t/3"
3414                        }
3415                    ]
3416                }));
3417            });
3418
3419            let client = create_test_client(&server);
3420
3421            // Search by title
3422            let issues = client
3423                .get_issues(IssueFilter {
3424                    search: Some("login".to_string()),
3425                    ..Default::default()
3426                })
3427                .await
3428                .unwrap()
3429                .items;
3430            assert_eq!(issues.len(), 2);
3431            assert!(issues.iter().any(|i| i.title == "Fix login bug"));
3432            assert!(issues.iter().any(|i| i.title == "Update docs")); // matches description
3433
3434            // Search by key
3435            let issues = client
3436                .get_issues(IssueFilter {
3437                    search: Some("CU-2".to_string()),
3438                    ..Default::default()
3439                })
3440                .await
3441                .unwrap()
3442                .items;
3443            assert_eq!(issues.len(), 1);
3444            assert_eq!(issues[0].title, "Add dark mode");
3445
3446            // Search — no matches
3447            let issues = client
3448                .get_issues(IssueFilter {
3449                    search: Some("nonexistent".to_string()),
3450                    ..Default::default()
3451                })
3452                .await
3453                .unwrap()
3454                .items;
3455            assert!(issues.is_empty());
3456        }
3457
3458        #[tokio::test]
3459        async fn test_get_issues_sort_by_priority() {
3460            let server = MockServer::start_async().await;
3461
3462            server.mock(|when, then| {
3463                when.method(GET).path("/list/12345/task");
3464                then.status(200).json_body(serde_json::json!({
3465                    "tasks": [
3466                        {
3467                            "id": "1", "name": "Low task",
3468                            "status": {"status": "open", "type": "open"},
3469                            "priority": {"id": "4", "priority": "low"},
3470                            "tags": [], "assignees": [],
3471                            "url": "https://app.clickup.com/t/1"
3472                        },
3473                        {
3474                            "id": "2", "name": "Urgent task",
3475                            "status": {"status": "open", "type": "open"},
3476                            "priority": {"id": "1", "priority": "urgent"},
3477                            "tags": [], "assignees": [],
3478                            "url": "https://app.clickup.com/t/2"
3479                        },
3480                        {
3481                            "id": "3", "name": "Normal task",
3482                            "status": {"status": "open", "type": "open"},
3483                            "priority": {"id": "3", "priority": "normal"},
3484                            "tags": [], "assignees": [],
3485                            "url": "https://app.clickup.com/t/3"
3486                        }
3487                    ]
3488                }));
3489            });
3490
3491            let client = create_test_client(&server);
3492
3493            // Sort by priority descending (most urgent first)
3494            let result = client
3495                .get_issues(IssueFilter {
3496                    sort_by: Some("priority".to_string()),
3497                    sort_order: Some("asc".to_string()),
3498                    ..Default::default()
3499                })
3500                .await
3501                .unwrap();
3502            assert_eq!(result.items[0].priority, Some("urgent".to_string()));
3503            assert_eq!(result.items[1].priority, Some("normal".to_string()));
3504            assert_eq!(result.items[2].priority, Some("low".to_string()));
3505
3506            // Verify sort_info is populated
3507            let sort_info = result.sort_info.unwrap();
3508            assert_eq!(sort_info.sort_by, Some("priority".to_string()));
3509            assert!(sort_info.available_sorts.contains(&"priority".into()));
3510        }
3511
3512        #[tokio::test]
3513        async fn test_get_issues_sort_by_title() {
3514            let server = MockServer::start_async().await;
3515
3516            server.mock(|when, then| {
3517                when.method(GET).path("/list/12345/task");
3518                then.status(200).json_body(serde_json::json!({
3519                    "tasks": [
3520                        {
3521                            "id": "1", "name": "Charlie",
3522                            "status": {"status": "open", "type": "open"},
3523                            "tags": [], "assignees": [],
3524                            "url": "https://app.clickup.com/t/1"
3525                        },
3526                        {
3527                            "id": "2", "name": "Alpha",
3528                            "status": {"status": "open", "type": "open"},
3529                            "tags": [], "assignees": [],
3530                            "url": "https://app.clickup.com/t/2"
3531                        },
3532                        {
3533                            "id": "3", "name": "Bravo",
3534                            "status": {"status": "open", "type": "open"},
3535                            "tags": [], "assignees": [],
3536                            "url": "https://app.clickup.com/t/3"
3537                        }
3538                    ]
3539                }));
3540            });
3541
3542            let client = create_test_client(&server);
3543
3544            let result = client
3545                .get_issues(IssueFilter {
3546                    sort_by: Some("title".to_string()),
3547                    sort_order: Some("asc".to_string()),
3548                    ..Default::default()
3549                })
3550                .await
3551                .unwrap();
3552            assert_eq!(result.items[0].title, "Alpha");
3553            assert_eq!(result.items[1].title, "Bravo");
3554            assert_eq!(result.items[2].title, "Charlie");
3555        }
3556
3557        #[tokio::test]
3558        async fn test_get_statuses_category_mapping() {
3559            let server = MockServer::start_async().await;
3560
3561            server.mock(|when, then| {
3562                when.method(GET).path("/list/12345");
3563                then.status(200).json_body(serde_json::json!({
3564                    "statuses": [
3565                        {"status": "Backlog", "type": "custom", "color": "#aaa", "orderindex": 0},
3566                        {"status": "To Do", "type": "open", "color": "#bbb", "orderindex": 1},
3567                        {"status": "In Progress", "type": "custom", "color": "#ccc", "orderindex": 2},
3568                        {"status": "In Review", "type": "custom", "color": "#ddd", "orderindex": 3},
3569                        {"status": "Done", "type": "closed", "color": "#eee", "orderindex": 4},
3570                        {"status": "Cancelled", "type": "custom", "color": "#fff", "orderindex": 5},
3571                        {"status": "Archived", "type": "custom", "color": "#000", "orderindex": 6}
3572                    ]
3573                }));
3574            });
3575
3576            let client = create_test_client(&server);
3577            let statuses = client.get_statuses().await.unwrap().items;
3578
3579            assert_eq!(statuses.len(), 7);
3580            assert_eq!(statuses[0].name, "Backlog");
3581            assert_eq!(statuses[0].category, "backlog");
3582            assert_eq!(statuses[1].name, "To Do");
3583            assert_eq!(statuses[1].category, "todo");
3584            assert_eq!(statuses[2].name, "In Progress");
3585            assert_eq!(statuses[2].category, "in_progress");
3586            assert_eq!(statuses[3].name, "In Review");
3587            assert_eq!(statuses[3].category, "in_progress");
3588            assert_eq!(statuses[4].name, "Done");
3589            assert_eq!(statuses[4].category, "done");
3590            assert_eq!(statuses[5].name, "Cancelled");
3591            assert_eq!(statuses[5].category, "cancelled");
3592            assert_eq!(statuses[6].name, "Archived");
3593            assert_eq!(statuses[6].category, "cancelled");
3594        }
3595
3596        #[tokio::test]
3597        async fn test_get_issues_state_category_filter() {
3598            let server = MockServer::start_async().await;
3599
3600            // Mock for get_statuses (called by stateCategory filter)
3601            server.mock(|when, then| {
3602                when.method(GET).path("/list/12345").query_param_exists("!");
3603                then.status(200).json_body(serde_json::json!({
3604                    "statuses": [
3605                        {"status": "Backlog", "type": "custom"},
3606                        {"status": "To Do", "type": "open"},
3607                        {"status": "In Progress", "type": "custom"},
3608                        {"status": "Done", "type": "closed"}
3609                    ]
3610                }));
3611            });
3612
3613            // This exact path mock for list info (no query params)
3614            server.mock(|when, then| {
3615                when.method(GET).path("/list/12345");
3616                then.status(200).json_body(serde_json::json!({
3617                    "statuses": [
3618                        {"status": "Backlog", "type": "custom"},
3619                        {"status": "To Do", "type": "open"},
3620                        {"status": "In Progress", "type": "custom"},
3621                        {"status": "Done", "type": "closed"}
3622                    ]
3623                }));
3624            });
3625
3626            server.mock(|when, then| {
3627                when.method(GET).path("/list/12345/task");
3628                then.status(200).json_body(serde_json::json!({
3629                    "tasks": [
3630                        {
3631                            "id": "1", "name": "Backlog task",
3632                            "status": {"status": "Backlog", "type": "custom"},
3633                            "tags": [], "assignees": [],
3634                            "url": "https://app.clickup.com/t/1"
3635                        },
3636                        {
3637                            "id": "2", "name": "In progress task",
3638                            "status": {"status": "In Progress", "type": "custom"},
3639                            "tags": [], "assignees": [],
3640                            "url": "https://app.clickup.com/t/2"
3641                        },
3642                        {
3643                            "id": "3", "name": "Todo task",
3644                            "status": {"status": "To Do", "type": "open"},
3645                            "tags": [], "assignees": [],
3646                            "url": "https://app.clickup.com/t/3"
3647                        }
3648                    ]
3649                }));
3650            });
3651
3652            let client = create_test_client(&server);
3653
3654            // Filter by in_progress category
3655            let issues = client
3656                .get_issues(IssueFilter {
3657                    state_category: Some("in_progress".to_string()),
3658                    ..Default::default()
3659                })
3660                .await
3661                .unwrap()
3662                .items;
3663            assert_eq!(issues.len(), 1);
3664            assert_eq!(issues[0].title, "In progress task");
3665
3666            // Filter by backlog category
3667            let issues = client
3668                .get_issues(IssueFilter {
3669                    state_category: Some("backlog".to_string()),
3670                    ..Default::default()
3671                })
3672                .await
3673                .unwrap()
3674                .items;
3675            assert_eq!(issues.len(), 1);
3676            assert_eq!(issues[0].title, "Backlog task");
3677        }
3678
3679        #[tokio::test]
3680        async fn test_get_issue_attachments_maps_all_fields() {
3681            let server = MockServer::start();
3682
3683            let task_json = serde_json::json!({
3684                "id": "abc123",
3685                "name": "Test",
3686                "status": {"status": "open", "type": "open"},
3687                "tags": [], "assignees": [],
3688                "url": "https://app.clickup.com/t/abc123",
3689                "date_created": "1704067200000",
3690                "date_updated": "1704067200000",
3691                "attachments": [
3692                    {
3693                        "id": "att-1",
3694                        "title": "screen.png",
3695                        "url": "https://attachments.clickup.com/abc/screen.png",
3696                        "size": "12345",
3697                        "extension": "png",
3698                        "mimetype": "image/png",
3699                        "date": "1704067200000",
3700                        "user": {"id": 7, "username": "uploader"}
3701                    }
3702                ]
3703            });
3704
3705            server.mock(|when, then| {
3706                when.method(GET).path("/task/abc123");
3707                then.status(200).json_body(task_json);
3708            });
3709
3710            let client = create_test_client(&server);
3711            let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3712            assert_eq!(assets.len(), 1);
3713            let a = &assets[0];
3714            assert_eq!(a.id, "att-1");
3715            assert_eq!(a.filename, "screen.png");
3716            assert_eq!(a.mime_type.as_deref(), Some("image/png"));
3717            assert_eq!(a.size, Some(12345));
3718            assert_eq!(a.author.as_deref(), Some("uploader"));
3719        }
3720
3721        #[tokio::test]
3722        async fn test_get_issue_attachments_empty_when_none() {
3723            let server = MockServer::start();
3724
3725            let task_json = serde_json::json!({
3726                "id": "abc123",
3727                "name": "Test",
3728                "status": {"status": "open", "type": "open"},
3729                "tags": [], "assignees": [],
3730                "url": "https://app.clickup.com/t/abc123",
3731                "date_created": "1704067200000",
3732                "date_updated": "1704067200000"
3733            });
3734
3735            server.mock(|when, then| {
3736                when.method(GET).path("/task/abc123");
3737                then.status(200).json_body(task_json);
3738            });
3739
3740            let client = create_test_client(&server);
3741            let assets = client.get_issue_attachments("CU-abc123").await.unwrap();
3742            assert!(assets.is_empty());
3743        }
3744
3745        #[tokio::test]
3746        async fn test_download_attachment_fetches_bytes() {
3747            let server = MockServer::start();
3748
3749            let task_json = serde_json::json!({
3750                "id": "abc123",
3751                "name": "Test",
3752                "status": {"status": "open", "type": "open"},
3753                "tags": [], "assignees": [],
3754                "url": "https://app.clickup.com/t/abc123",
3755                "date_created": "1704067200000",
3756                "date_updated": "1704067200000",
3757                "attachments": [
3758                    {
3759                        "id": "att-1",
3760                        "title": "log.txt",
3761                        "url": format!("{}/download/att-1", server.base_url()),
3762                    }
3763                ]
3764            });
3765
3766            server.mock(|when, then| {
3767                when.method(GET).path("/task/abc123");
3768                then.status(200).json_body(task_json);
3769            });
3770            server.mock(|when, then| {
3771                when.method(GET).path("/download/att-1");
3772                then.status(200).body("hello world");
3773            });
3774
3775            let client = create_test_client(&server);
3776            let bytes = client
3777                .download_attachment("CU-abc123", "att-1")
3778                .await
3779                .unwrap();
3780            assert_eq!(bytes, b"hello world");
3781        }
3782
3783        #[tokio::test]
3784        async fn test_download_attachment_not_found() {
3785            let server = MockServer::start();
3786
3787            server.mock(|when, then| {
3788                when.method(GET).path("/task/abc123");
3789                then.status(200).json_body(serde_json::json!({
3790                    "id": "abc123", "name": "Test",
3791                    "status": {"status": "open", "type": "open"},
3792                    "tags": [], "assignees": [],
3793                    "url": "https://app.clickup.com/t/abc123",
3794                    "date_created": "1704067200000",
3795                    "date_updated": "1704067200000",
3796                    "attachments": []
3797                }));
3798            });
3799
3800            let client = create_test_client(&server);
3801            let err = client
3802                .download_attachment("CU-abc123", "missing")
3803                .await
3804                .unwrap_err();
3805            assert!(matches!(err, Error::NotFound(_)));
3806        }
3807    }
3808}