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