Skip to main content

suno_core/
client.rs

1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use std::collections::BTreeSet;
4
5use serde_json::Value;
6
7use crate::auth::ClerkAuth;
8use crate::backoff::{backoff_delay, retry_after};
9use crate::clock::Clock;
10use crate::consts::{
11    API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
12    FEED_V3_PATH, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
13};
14use crate::error::{Error, Result};
15use crate::http::{Http, HttpRequest, Method};
16use crate::is_downloadable;
17use crate::limiter::{AdaptiveLimiter, retry_after_delay};
18use crate::model::Clip;
19
20/// One of the account's own playlists, as listed by `/api/playlist/me`.
21///
22/// Carries only what playlist reconciliation needs: the stable id (the state
23/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
24/// and the member count for reporting. The ordered members are fetched
25/// separately with [`SunoClient::get_playlist_clips`].
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Playlist {
28    /// The playlist's stable Suno id.
29    pub id: String,
30    /// The playlist's display name.
31    pub name: String,
32    /// The number of clips Suno reports in the playlist.
33    pub num_clips: u64,
34}
35
36/// The authenticated account's current remaining credit balance.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BillingInfo {
39    /// Credits remaining in the current billing state.
40    pub total_credits_left: u64,
41}
42
43/// A client for the Suno library API, owning the account's [`ClerkAuth`].
44///
45/// The [`Clock`] is held so [`api_request`](Self::api_request) can back off
46/// through the port on a `429` or transient failure — the engine still sleeps
47/// nowhere itself. The [`AdaptiveLimiter`] paces reactively: an unthrottled
48/// listing waits nowhere, and only after a `429` does it space requests out,
49/// halving the rate and ramping it back after a run of clean successes so pacing
50/// tracks Suno's real limit rather than a fixed constant.
51pub struct SunoClient<C> {
52    auth: ClerkAuth,
53    clock: C,
54    limiter: AdaptiveLimiter,
55}
56
57impl<C: Clock> SunoClient<C> {
58    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
59    pub fn new(auth: ClerkAuth, clock: C) -> Self {
60        Self {
61            auth,
62            clock,
63            limiter: AdaptiveLimiter::new(FEED_INITIAL_RATE),
64        }
65    }
66
67    /// Borrow the underlying authenticator.
68    pub fn auth(&self) -> &ClerkAuth {
69        &self.auth
70    }
71
72    /// The adaptive limiter's current requests-per-second rate, for tests that
73    /// assert the limiter still records success and `429` correctly (including
74    /// under concurrent WAV-render calls serialised through the executor).
75    #[cfg(test)]
76    pub(crate) fn limiter_rate(&self) -> f64 {
77        self.limiter.rate()
78    }
79
80    /// List clips across the whole library, or only liked clips.
81    ///
82    /// Walks the cursor-paginated `POST /api/feed/v3` feed, following
83    /// `next_cursor` until the server reports the end. Once `limit` clips have
84    /// been collected it stops at the next page boundary and truncates to
85    /// `limit`. Paging is hard-capped at [`MAX_PAGES`] so a runaway
86    /// `has_more` can never loop forever. When `liked` is set the feed filter
87    /// scopes to liked clips (`liked: "True"`).
88    ///
89    /// Returns the clips paired with a `complete` flag that is `true` only when
90    /// paging ended because the server reported `has_more == false` (the feed
91    /// fully drained). A missing `has_more`, a `has_more == true` page with no
92    /// usable `next_cursor`, a `limit` stop, exhausting [`MAX_PAGES`], or any
93    /// transport error all yield `false` (or propagate) so the caller can refuse
94    /// to treat a truncated listing as authoritative for deletion.
95    pub async fn list_clips(
96        &mut self,
97        http: &impl Http,
98        liked: bool,
99        limit: Option<usize>,
100    ) -> Result<(Vec<Clip>, bool)> {
101        let mut clips = Vec::new();
102        let mut cursor: Option<String> = None;
103        let mut complete = false;
104        for _ in 0..MAX_PAGES {
105            let body = feed_v3_body(liked, cursor.as_deref());
106            let response = self
107                .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
108                .await?;
109            let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
110            clips.extend(page_clips);
111            match has_more {
112                Some(false) => {
113                    complete = true;
114                    break;
115                }
116                Some(true) => match next_cursor {
117                    Some(next) => cursor = Some(next),
118                    None => break,
119                },
120                None => break,
121            }
122            if limit.is_some_and(|n| clips.len() >= n) {
123                break;
124            }
125        }
126        if let Some(n) = limit {
127            clips.truncate(n);
128        }
129        Ok((clips, complete))
130    }
131
132    /// Fetch one clip by ID.
133    ///
134    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
135    /// scanning the library feed, since that endpoint's exact shape is not yet
136    /// confirmed against the live API.
137    pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
138        if let Some(clip) = self.try_get_clip(http, id).await? {
139            return Ok(clip);
140        }
141        self.find_in_feed(http, id).await
142    }
143
144    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
145    pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
146        let path = format!("/api/gen/{id}/convert_wav/");
147        self.api_request(http, Method::Post, &path, Vec::new())
148            .await?;
149        Ok(())
150    }
151
152    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
153    pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
154        let path = format!("/api/gen/{id}/wav_file/");
155        let body = self.api_get(http, &path).await?;
156        let data: Value = serde_json::from_slice(&body)
157            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
158        Ok(data
159            .get("wav_file_url")
160            .and_then(Value::as_str)
161            .filter(|url| !url.is_empty())
162            .map(str::to_string))
163    }
164
165    /// Fetch specific clips by id, one `GET /api/clip/{id}` per id.
166    ///
167    /// Used by lineage resolution to gap-fill ancestors that are absent from a
168    /// normal listing, including trashed ones. The v3 feed has no batch by-id
169    /// filter, so each id is fetched individually; `/api/clip/{id}` returns any
170    /// clip, trashed or artefact, with the full field set. Unlike
171    /// [`list_clips`](Self::list_clips), no downloadability filter is applied: an
172    /// ancestor may itself be an infill or context-window artefact that the
173    /// lineage walk must still traverse. Clips returned here are ancestors for
174    /// resolution only and must never be treated as download candidates. Ids are
175    /// deduplicated in order, and an id that cannot be retrieved (a `404`) is
176    /// skipped so the caller can fall back to the parent endpoint.
177    pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
178        let mut clips = Vec::new();
179        let mut seen: BTreeSet<&str> = BTreeSet::new();
180        for id in ids {
181            if id.is_empty() || !seen.insert(id) {
182                continue;
183            }
184            let path = format!("/api/clip/{id}");
185            match self.api_get_retrying(http, &path).await {
186                Ok(body) => {
187                    if let Some(clip) = parse_clip(&body) {
188                        clips.push(clip);
189                    }
190                }
191                Err(Error::NotFound(_)) => continue,
192                Err(err) => return Err(err),
193            }
194        }
195        Ok(clips)
196    }
197
198    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
199    ///
200    /// Returns the parent clip, or `None` when the clip is a root (no parent) or
201    /// the endpoint yields no clip. Lineage resolution uses this as a fallback
202    /// when a missing ancestor cannot be retrieved by id. Only a `404` (the clip
203    /// has no parent) maps to `None`; any other failure, including a transient
204    /// `5xx`, propagates as an error rather than being mistaken for a root.
205    pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
206        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
207        match self.api_get_retrying(http, &path).await {
208            Ok(body) => Ok(parse_clip(&body)),
209            Err(Error::NotFound(_)) => Ok(None),
210            Err(err) => Err(err),
211        }
212    }
213
214    /// List the account's own playlists, paging `/api/playlist/me`.
215    ///
216    /// Trashed and share-list playlists are excluded by query, so the result is
217    /// the account's authoritative own set. Paging stops on the first empty page
218    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
219    /// parameter cannot loop forever. Only entries with a non-empty id are kept.
220    ///
221    /// A hard failure propagates as an error; the caller treats that as "the
222    /// playlist listing did not fully enumerate" and refuses every playlist
223    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
224    pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
225        let mut playlists = Vec::new();
226        for page in 1..=MAX_PAGES {
227            let path =
228                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
229            let body = self.api_get_retrying(http, &path).await?;
230            let page_playlists = parse_playlists(&body)?;
231            if page_playlists.is_empty() {
232                break;
233            }
234            playlists.extend(page_playlists);
235        }
236        Ok(playlists)
237    }
238
239    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
240    ///
241    /// The response's `playlist_clips[]` is already ordered and trashed members
242    /// are excluded by Suno, so the order is preserved exactly and no
243    /// downloadability filter is applied: a playlist may legitimately contain any
244    /// clip. Each entry's `clip` object is mapped (falling back to the entry
245    /// itself), and only clips with a non-empty id are kept.
246    ///
247    /// The returned `bool` is a completeness signal for deletion authority: the
248    /// endpoint reports `num_total_results` (the playlist's full member count)
249    /// alongside `playlist_clips[]`, so `true` means every member came back on
250    /// this single page (`returned == num_total_results`). A short page (a
251    /// paginated or partially-listed playlist) returns `false`, so a Mirror
252    /// playlist area under `library = "off"` is never treated as authoritative
253    /// unless its whole member set was seen (D5).
254    pub async fn get_playlist_clips(
255        &mut self,
256        http: &impl Http,
257        id: &str,
258    ) -> Result<(Vec<Clip>, bool)> {
259        let path = format!("{PLAYLIST_PATH}{id}/");
260        let body = self.api_get_retrying(http, &path).await?;
261        parse_playlist_clips(&body)
262    }
263
264    /// Read the authenticated account's billing information.
265    pub async fn get_billing_info(&mut self, http: &impl Http) -> Result<BillingInfo> {
266        let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
267        parse_billing_info(&body)
268    }
269
270    /// Try the dedicated clip endpoint, returning `None` when it is missing or
271    /// returns a body that does not yield the requested clip.
272    async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
273        let path = format!("/api/clip/{id}");
274        match self.api_get_retrying(http, &path).await {
275            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
276            Err(Error::NotFound(_)) => Ok(None),
277            Err(err) => Err(err),
278        }
279    }
280
281    /// Locate a clip by scanning the library feed.
282    async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
283        let (clips, _complete) = self.list_clips(http, false, None).await?;
284        clips
285            .into_iter()
286            .find(|clip| clip.id == id)
287            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
288    }
289
290    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
291    async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
292        self.api_request(http, Method::Get, path, Vec::new()).await
293    }
294
295    /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
296    async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
297        self.api_send_retrying(http, Method::Get, path, Vec::new())
298            .await
299    }
300
301    /// Like [`api_request`](Self::api_request) but rides through Suno's rate
302    /// limiter, pacing each request to the adaptive rate and backing off through
303    /// the [`Clock`] on a `429` (honouring `Retry-After` when present, defaulting
304    /// to 5s and capped at 60s) or a transient connection failure, up to
305    /// [`API_MAX_RETRIES`] times. Each attempt reconstructs the full request
306    /// (method, path, and body), so a throttled feed page re-POSTs the same
307    /// cursor rather than skipping ahead.
308    ///
309    /// Pacing lives here, at the single per-request layer, rather than in any
310    /// paged walk, so it composes with whatever listing calls it: a page or a
311    /// cursor walk pace identically. The [`AdaptiveLimiter`] paces reactively:
312    /// an unthrottled walk waits nowhere, and only after the first `429` does it
313    /// space out requests, widening that pace as the rate is halved again.
314    ///
315    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
316    /// the executor owns that retry so its budget and poll interval stay in one
317    /// place. Library, playlist, and lineage reads use this so a full-library
318    /// walk is not aborted by a single throttled page.
319    async fn api_send_retrying(
320        &mut self,
321        http: &impl Http,
322        method: Method,
323        path: &str,
324        body: Vec<u8>,
325    ) -> Result<Vec<u8>> {
326        let pace = self.limiter.pace();
327        if !pace.is_zero() {
328            self.clock.sleep(pace).await;
329        }
330        let mut retries = 0;
331        loop {
332            match self.api_request(http, method, path, body.clone()).await {
333                Ok(response) => return Ok(response),
334                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
335                    self.clock.sleep(retry_after_delay(retry_after)).await;
336                    retries += 1;
337                }
338                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
339                    self.clock.sleep(backoff_delay(retries, None)).await;
340                    retries += 1;
341                }
342                Err(err) => return Err(err),
343            }
344        }
345    }
346
347    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
348    ///
349    /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
350    /// POST reaches the network unchanged.
351    async fn api_request(
352        &mut self,
353        http: &impl Http,
354        method: Method,
355        path: &str,
356        body: Vec<u8>,
357    ) -> Result<Vec<u8>> {
358        let url = format!("{SUNO_API_BASE_URL}{path}");
359        let mut auth_refreshed = false;
360        loop {
361            let jwt = self.auth.ensure_jwt(http).await?;
362            let mut request = match method {
363                Method::Get => HttpRequest::get(url.clone()),
364                Method::Post => HttpRequest::post(url.clone(), body.clone()),
365            };
366            request
367                .headers
368                .push(("Authorization".to_string(), format!("Bearer {jwt}")));
369            let response = http
370                .send(request)
371                .await
372                .map_err(|err| Error::Connection(err.to_string()))?;
373            match response.status {
374                200..=299 => {
375                    self.limiter.on_success();
376                    return Ok(response.body);
377                }
378                401 | 403 if !auth_refreshed => {
379                    self.auth.invalidate_jwt();
380                    auth_refreshed = true;
381                }
382                401 | 403 => {
383                    return Err(Error::Auth(format!(
384                        "Suno API auth failed with status {}",
385                        response.status
386                    )));
387                }
388                429 => {
389                    self.limiter.on_rate_limit();
390                    return Err(Error::RateLimited {
391                        retry_after: retry_after(&response),
392                    });
393                }
394                404 => {
395                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
396                }
397                status => {
398                    let preview: String = String::from_utf8_lossy(&response.body)
399                        .chars()
400                        .take(200)
401                        .collect();
402                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
403                }
404            }
405        }
406    }
407}
408
409/// Unwrap a `{ "clip": {...} }` wrapper to the inner clip object, or return
410/// `value` unchanged when it carries no object `clip` key (it is already bare).
411fn unwrap_clip(value: &Value) -> &Value {
412    value
413        .get("clip")
414        .filter(|clip| clip.is_object())
415        .unwrap_or(value)
416}
417
418/// Parse a single-clip response body, accepting either a bare clip object or a
419/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
420fn parse_clip(body: &[u8]) -> Option<Clip> {
421    let data: Value = serde_json::from_slice(body).ok()?;
422    let raw = unwrap_clip(&data);
423    let has_id = raw
424        .get("id")
425        .and_then(Value::as_str)
426        .is_some_and(|id| !id.is_empty());
427    has_id.then(|| Clip::from_json(raw))
428}
429
430/// Parse `/api/billing/info/` into the remaining credits we report in `doctor`.
431fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
432    let data: Value = serde_json::from_slice(body)
433        .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
434    let total_credits_left = data
435        .get("total_credits_left")
436        .and_then(json_u64)
437        .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
438    Ok(BillingInfo { total_credits_left })
439}
440
441/// Read a numeric field that Suno may encode either as a JSON number or a
442/// decimal string.
443fn json_u64(value: &Value) -> Option<u64> {
444    match value {
445        Value::Number(number) => number.as_u64(),
446        Value::String(text) => text.parse().ok(),
447        _ => None,
448    }
449}
450
451/// Build the JSON body for a `POST /api/feed/v3` page.
452///
453/// `filters.trashed` is the string `"False"` so the feed excludes trashed clips
454/// exactly as the old v2 listing did; a `liked` walk adds `filters.liked =
455/// "True"` (v3 ignores an `is_liked` key). The `cursor` is omitted on the first
456/// page and set to the previous page's `next_cursor` thereafter.
457fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
458    let mut filters = serde_json::Map::new();
459    filters.insert("trashed".to_string(), Value::String("False".to_string()));
460    if liked {
461        filters.insert("liked".to_string(), Value::String("True".to_string()));
462    }
463    let mut body = serde_json::Map::new();
464    body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
465    body.insert("filters".to_string(), Value::Object(filters));
466    if let Some(cursor) = cursor {
467        body.insert("cursor".to_string(), Value::String(cursor.to_string()));
468    }
469    serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
470}
471
472/// Parse a v3 feed page into the kept clips, the raw `has_more`, and the
473/// `next_cursor`.
474///
475/// `has_more` is [`None`] when the key is missing or not a bool, so the caller
476/// can refuse to treat an unrecognised page as a fully drained feed. An empty
477/// `next_cursor` string maps to [`None`] so it is never re-sent as a cursor.
478fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
479    let data: Value = serde_json::from_slice(body)
480        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
481    let Some(object) = data.as_object() else {
482        return Ok((Vec::new(), None, None));
483    };
484    let clips = object
485        .get("clips")
486        .and_then(Value::as_array)
487        .map(|raw| {
488            raw.iter()
489                .map(Clip::from_json)
490                .filter(is_downloadable)
491                .collect()
492        })
493        .unwrap_or_default();
494    let has_more = object.get("has_more").and_then(Value::as_bool);
495    let next_cursor = object
496        .get("next_cursor")
497        .and_then(Value::as_str)
498        .filter(|cursor| !cursor.is_empty())
499        .map(str::to_string);
500    Ok((clips, has_more, next_cursor))
501}
502
503/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
504fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
505    let data: Value = serde_json::from_slice(body)
506        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
507    Ok(data
508        .get("playlists")
509        .and_then(Value::as_array)
510        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
511        .unwrap_or_default())
512}
513
514/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
515///
516/// `num_total_results` is the playlist's member count; a missing name defaults
517/// to `Untitled` (matching the clip mapping) so the file name is never empty.
518fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
519    let id = raw
520        .get("id")
521        .and_then(Value::as_str)
522        .filter(|id| !id.is_empty())?
523        .to_string();
524    let name = match raw.get("name") {
525        Some(Value::String(name)) if !name.is_empty() => name.clone(),
526        _ => "Untitled".to_string(),
527    };
528    let num_clips = raw
529        .get("num_total_results")
530        .and_then(Value::as_u64)
531        .unwrap_or(0);
532    Some(Playlist {
533        id,
534        name,
535        num_clips,
536    })
537}
538
539/// Parse a `/api/playlist/{id}/` body into its ordered member clips plus a
540/// completeness flag.
541///
542/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
543/// unwrapped (falling back to the entry itself), order is preserved exactly, and
544/// only clips with a non-empty id survive. No downloadability filter is applied:
545/// a playlist may hold any clip, and members absent from the local library are
546/// reconciled as comment lines by the caller, not dropped here. The scoped-sync
547/// path applies [`is_downloadable`](crate::is_downloadable) itself when it fetches
548/// members as download candidates.
549///
550/// The completeness flag is `true` when the number of raw `playlist_clips[]`
551/// entries equals the response's `num_total_results`, i.e. the whole member set
552/// arrived on this single page. It gates a Mirror playlist area's deletion
553/// authority (D5): a short or paginated page cannot be authoritative for
554/// deletion, so it returns `false`.
555fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
556    let data: Value = serde_json::from_slice(body)
557        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
558    let raw = data.get("playlist_clips").and_then(Value::as_array);
559    let raw_len = raw.map(|a| a.len()).unwrap_or(0);
560    let clips: Vec<Clip> = raw
561        .map(|raw| {
562            raw.iter()
563                .map(|entry| Clip::from_json(unwrap_clip(entry)))
564                .filter(|clip| !clip.id.is_empty())
565                .collect()
566        })
567        .unwrap_or_default();
568    // Completeness compares the raw entry count (before the empty-id filter)
569    // against the reported total: a full single page has them equal. A missing
570    // or malformed total is never treated as complete, so a page whose size
571    // cannot be verified fails safe toward "not authoritative" and a Mirror area
572    // can never delete from it.
573    let complete = data
574        .get("num_total_results")
575        .and_then(Value::as_u64)
576        .is_some_and(|total| raw_len as u64 == total);
577    Ok((clips, complete))
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
584    use std::time::Duration;
585
586    fn feed_body() -> String {
587        serde_json::json!({
588            "has_more": false,
589            "clips": [
590                {
591                    "id": "a", "title": "Song A", "status": "complete",
592                    "audio_url": "https://cdn1.suno.ai/a.mp3",
593                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
594                },
595                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
596                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
597                {
598                    "id": "d", "title": "Context", "status": "complete",
599                    "metadata": {"type": "rendered_context_window"}
600                }
601            ]
602        })
603        .to_string()
604    }
605
606    #[test]
607    fn parse_feed_v3_filters_and_reads_pagination() {
608        let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
609        assert_eq!(has_more, Some(false));
610        assert_eq!(next_cursor, None);
611        assert_eq!(clips.len(), 1);
612        assert_eq!(clips[0].id, "a");
613        assert_eq!(clips[0].tags, "rock");
614        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
615    }
616
617    #[test]
618    fn feed_v3_body_carries_filters_and_optional_cursor() {
619        let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
620        assert_eq!(first["filters"]["trashed"], "False");
621        assert!(first.get("cursor").is_none());
622        assert!(first["filters"].get("liked").is_none());
623
624        let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
625        assert_eq!(liked["filters"]["liked"], "True");
626        assert_eq!(liked["cursor"], "cur42");
627    }
628
629    #[test]
630    fn audiopipe_url_is_rewritten_to_cdn() {
631        let raw =
632            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
633        assert_eq!(
634            Clip::from_json(&raw).audio_url,
635            "https://cdn1.suno.ai/x.mp3"
636        );
637    }
638
639    #[test]
640    fn list_clips_authenticates_then_reads_the_feed() {
641        let client_body = serde_json::json!({
642            "response": {
643                "last_active_session_id": "s",
644                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
645            }
646        })
647        .to_string();
648        let http = MockHttp::new(vec![
649            Rule::new(
650                "/v1/client/sessions/",
651                200,
652                r#"{"jwt": "a.b.c"}"#.to_string(),
653            ),
654            Rule::new("/v1/client", 200, client_body),
655            Rule::new("/api/feed/v3", 200, feed_body()),
656        ]);
657
658        let mut auth = ClerkAuth::new("eyJtoken");
659        pollster::block_on(auth.authenticate(&http)).unwrap();
660        let mut client = SunoClient::new(auth, RecordingClock::new());
661        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
662        assert_eq!(clips.len(), 1);
663        assert_eq!(clips[0].id, "a");
664        assert!(complete);
665    }
666
667    #[test]
668    fn list_clips_reports_incomplete_when_paging_is_capped() {
669        let mut rules = auth_rules();
670        rules.push(Rule::new(
671            "/api/feed/v3",
672            200,
673            serde_json::json!({
674                "has_more": true,
675                "next_cursor": "cur1",
676                "clips": [{
677                    "id": "a", "title": "Song A", "status": "complete",
678                    "audio_url": "https://cdn1.suno.ai/a.mp3",
679                    "metadata": {"type": "gen"}
680                }]
681            })
682            .to_string(),
683        ));
684        let http = MockHttp::new(rules);
685        let mut client = authed_client(&http);
686
687        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
688        assert!(!complete);
689    }
690
691    fn auth_rules() -> Vec<Rule> {
692        let client_body = serde_json::json!({
693            "response": {
694                "last_active_session_id": "s",
695                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
696            }
697        })
698        .to_string();
699        vec![
700            Rule::new(
701                "/v1/client/sessions/",
702                200,
703                r#"{"jwt": "a.b.c"}"#.to_string(),
704            ),
705            Rule::new("/v1/client", 200, client_body),
706        ]
707    }
708
709    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
710        let mut auth = ClerkAuth::new("eyJtoken");
711        pollster::block_on(auth.authenticate(http)).unwrap();
712        SunoClient::new(auth, RecordingClock::new())
713    }
714
715    #[test]
716    fn get_billing_info_reads_remaining_credits() {
717        let mut rules = auth_rules();
718        rules.push(Rule::new(
719            BILLING_INFO_PATH,
720            200,
721            r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
722        ));
723        let http = MockHttp::new(rules);
724        let mut client = authed_client(&http);
725
726        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
727        assert_eq!(billing.total_credits_left, 500);
728    }
729
730    #[test]
731    fn get_billing_info_rejects_missing_balance() {
732        let mut rules = auth_rules();
733        rules.push(Rule::new(
734            BILLING_INFO_PATH,
735            200,
736            r#"{"monthly_usage":12}"#.to_string(),
737        ));
738        let http = MockHttp::new(rules);
739        let mut client = authed_client(&http);
740
741        let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
742        assert!(err.to_string().contains("total_credits_left"));
743    }
744
745    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
746        let mut auth = ClerkAuth::new("eyJtoken");
747        pollster::block_on(auth.authenticate(http)).unwrap();
748        SunoClient::new(auth, clock)
749    }
750
751    fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
752        let mut page = serde_json::json!({
753            "has_more": next_cursor.is_some(),
754            "clips": [{
755                "id": id, "title": "Song", "status": "complete",
756                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
757                "metadata": {"type": "gen"}
758            }]
759        });
760        if let Some(cursor) = next_cursor {
761            page["next_cursor"] = serde_json::json!(cursor);
762        }
763        page.to_string()
764    }
765
766    #[test]
767    fn list_clips_retries_a_rate_limited_page() {
768        let http = ScriptedHttp::new().with_auth().route_seq(
769            "/api/feed/v3",
770            vec![Reply::status(429), Reply::json(&feed_body())],
771        );
772        let clock = RecordingClock::new();
773        let mut client = scripted_client(&http, clock.clone());
774
775        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
776        assert_eq!(clips.len(), 1);
777        assert!(complete);
778        // The throttled page was retried once, waiting the default post-429 wait.
779        assert_eq!(http.count("/api/feed/v3"), 2);
780        assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
781    }
782
783    #[test]
784    fn list_clips_honours_retry_after_on_a_throttled_page() {
785        let http = ScriptedHttp::new().with_auth().route_seq(
786            "/api/feed/v3",
787            vec![
788                Reply::status(429).with_retry_after(7),
789                Reply::json(&feed_body()),
790            ],
791        );
792        let clock = RecordingClock::new();
793        let mut client = scripted_client(&http, clock.clone());
794
795        let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
796        assert_eq!(clips.len(), 1);
797        // The server's Retry-After is honoured directly as the post-429 wait.
798        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
799    }
800
801    #[test]
802    fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
803        // A 429 mid-walk must re-POST the *same* cursor, not skip a page.
804        let http = ScriptedHttp::new().with_auth().route_seq(
805            "/api/feed/v3",
806            vec![
807                Reply::json(&one_clip_page("a", Some("cur1"))),
808                Reply::status(429),
809                Reply::json(&one_clip_page("b", None)),
810            ],
811        );
812        let clock = RecordingClock::new();
813        let mut client = scripted_client(&http, clock.clone());
814
815        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
816        assert!(complete);
817        assert_eq!(clips.len(), 2);
818        let bodies = http.bodies();
819        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
820        assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
821        // The retry (body 2) carries the SAME cursor as the throttled call (body 2 == the
822        // second feed POST), i.e. the cursor from page 1's next_cursor.
823        let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
824        let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
825        assert_eq!(retried["cursor"], "cur1");
826        assert_eq!(after_retry["cursor"], "cur1");
827    }
828
829    #[test]
830    fn list_clips_threads_the_cursor_across_pages() {
831        let http = ScriptedHttp::new().with_auth().route_seq(
832            "/api/feed/v3",
833            vec![
834                Reply::json(&one_clip_page("a", Some("cur1"))),
835                Reply::json(&one_clip_page("b", None)),
836            ],
837        );
838        let clock = RecordingClock::new();
839        let mut client = scripted_client(&http, clock.clone());
840
841        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
842        assert!(complete);
843        assert_eq!(clips.len(), 2);
844        let bodies = http.bodies();
845        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
846        assert_eq!(feed_bodies.len(), 2);
847        let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
848        let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
849        // Page 1 omits the cursor; page 2 carries exactly page 1's next_cursor.
850        assert!(page1.get("cursor").is_none());
851        assert_eq!(page2["cursor"], "cur1");
852    }
853
854    #[test]
855    fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
856        // has_more == true with no usable next_cursor: a truncated feed. The walk
857        // must stop, report incomplete, and never re-POST a null cursor.
858        let page = serde_json::json!({
859            "has_more": true,
860            "clips": [{
861                "id": "a", "title": "Song", "status": "complete",
862                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
863            }]
864        })
865        .to_string();
866        let http = ScriptedHttp::new()
867            .with_auth()
868            .route("/api/feed/v3", Reply::json(&page));
869        let clock = RecordingClock::new();
870        let mut client = scripted_client(&http, clock.clone());
871
872        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
873        assert!(!complete);
874        assert_eq!(clips.len(), 1);
875        assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
876    }
877
878    #[test]
879    fn list_clips_is_incomplete_when_has_more_is_missing() {
880        // A page with no has_more key must not be read as a fully drained feed.
881        let page = serde_json::json!({
882            "clips": [{
883                "id": "a", "title": "Song", "status": "complete",
884                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
885            }]
886        })
887        .to_string();
888        let http = ScriptedHttp::new()
889            .with_auth()
890            .route("/api/feed/v3", Reply::json(&page));
891        let clock = RecordingClock::new();
892        let mut client = scripted_client(&http, clock.clone());
893
894        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
895        assert!(!complete);
896        assert_eq!(clips.len(), 1);
897        assert_eq!(http.count("/api/feed/v3"), 1);
898    }
899
900    #[test]
901    fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
902        let http = ScriptedHttp::new().with_auth().route_seq(
903            "/api/feed/v3",
904            vec![
905                Reply::json(&one_clip_page("a", Some("cur1"))),
906                Reply::status(500),
907            ],
908        );
909        let clock = RecordingClock::new();
910        let mut client = scripted_client(&http, clock.clone());
911
912        let result = pollster::block_on(client.list_clips(&http, false, None));
913        assert!(matches!(result, Err(Error::Api(_))));
914    }
915
916    #[test]
917    fn list_clips_is_complete_on_an_empty_drained_feed() {
918        // An empty but fully drained feed is authoritative (complete = true);
919        // deletion is separately gated by there being a mirror source.
920        let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
921        let http = ScriptedHttp::new()
922            .with_auth()
923            .route("/api/feed/v3", Reply::json(&page));
924        let clock = RecordingClock::new();
925        let mut client = scripted_client(&http, clock.clone());
926
927        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
928        assert!(complete);
929        assert!(clips.is_empty());
930    }
931
932    #[test]
933    fn list_clips_liked_scope_sends_the_liked_filter() {
934        let http = ScriptedHttp::new()
935            .with_auth()
936            .route("/api/feed/v3", Reply::json(&feed_body()));
937        let clock = RecordingClock::new();
938        let mut client = scripted_client(&http, clock.clone());
939
940        let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
941        let bodies = http.bodies();
942        let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
943        let value: Value = serde_json::from_str(feed_body).unwrap();
944        assert_eq!(value["filters"]["liked"], "True");
945        assert_eq!(value["filters"]["trashed"], "False");
946    }
947
948    #[test]
949    fn list_clips_does_not_pace_an_unthrottled_walk() {
950        let http = ScriptedHttp::new().with_auth().route_seq(
951            "/api/feed/v3",
952            vec![
953                Reply::json(&one_clip_page("a", Some("cur1"))),
954                Reply::json(&one_clip_page("e", None)),
955            ],
956        );
957        let clock = RecordingClock::new();
958        let mut client = scripted_client(&http, clock.clone());
959
960        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
961        assert!(complete);
962        assert_eq!(clips.len(), 2);
963        assert_eq!(http.count("/api/feed/v3"), 2);
964        // Pacing is reactive: with no 429 the whole walk waits nowhere.
965        assert!(clock.sleeps().is_empty());
966    }
967
968    #[test]
969    fn list_clips_slows_its_pace_after_a_throttled_page() {
970        let http = ScriptedHttp::new().with_auth().route_seq(
971            "/api/feed/v3",
972            vec![
973                Reply::status(429),
974                Reply::json(&one_clip_page("a", Some("cur1"))),
975                Reply::json(&one_clip_page("e", None)),
976            ],
977        );
978        let clock = RecordingClock::new();
979        let mut client = scripted_client(&http, clock.clone());
980
981        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
982        assert!(complete);
983        assert_eq!(clips.len(), 2);
984        // The 429 halved the rate, so the default post-429 wait is followed by a
985        // doubled inter-page pace (500ms to 1s) for the next page.
986        assert_eq!(
987            clock.sleeps(),
988            vec![Duration::from_secs(5), Duration::from_secs(1)]
989        );
990    }
991
992    #[test]
993    fn list_clips_gives_up_after_max_retries() {
994        let http = ScriptedHttp::new()
995            .with_auth()
996            .route("/api/feed/v3", Reply::status(429));
997        let clock = RecordingClock::new();
998        let mut client = scripted_client(&http, clock.clone());
999
1000        let result = pollster::block_on(client.list_clips(&http, false, None));
1001        assert!(matches!(result, Err(Error::RateLimited { .. })));
1002        let budget = crate::consts::API_MAX_RETRIES as usize;
1003        assert_eq!(clock.sleeps().len(), budget);
1004        assert_eq!(http.count("/api/feed/v3"), budget + 1);
1005    }
1006
1007    #[test]
1008    fn parse_clip_accepts_bare_and_wrapped_shapes() {
1009        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1010        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1011
1012        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1013        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1014
1015        let missing = serde_json::json!({"detail": "not found"}).to_string();
1016        assert!(parse_clip(missing.as_bytes()).is_none());
1017    }
1018
1019    #[test]
1020    fn get_clip_uses_the_dedicated_endpoint() {
1021        let clip_body = serde_json::json!({
1022            "id": "z", "title": "Zed", "status": "complete",
1023            "audio_url": "https://cdn1.suno.ai/z.mp3",
1024            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1025        })
1026        .to_string();
1027        let mut rules = auth_rules();
1028        rules.push(Rule::new("/api/clip/", 200, clip_body));
1029        let http = MockHttp::new(rules);
1030        let mut client = authed_client(&http);
1031
1032        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1033        assert_eq!(clip.id, "z");
1034        assert_eq!(clip.title, "Zed");
1035        assert_eq!(clip.tags, "jazz");
1036    }
1037
1038    #[test]
1039    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1040        let mut rules = auth_rules();
1041        rules.push(Rule::new(
1042            "/api/clip/",
1043            404,
1044            r#"{"detail": "not found"}"#.to_string(),
1045        ));
1046        rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1047        let http = MockHttp::new(rules);
1048        let mut client = authed_client(&http);
1049
1050        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1051        assert_eq!(clip.id, "a");
1052        assert_eq!(clip.tags, "rock");
1053    }
1054
1055    #[test]
1056    fn request_wav_accepts_a_2xx_status() {
1057        let mut rules = auth_rules();
1058        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1059        let http = MockHttp::new(rules);
1060        let mut client = authed_client(&http);
1061
1062        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1063    }
1064
1065    #[test]
1066    fn wav_url_reads_the_ready_url() {
1067        let mut rules = auth_rules();
1068        rules.push(Rule::new(
1069            "/wav_file/",
1070            200,
1071            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1072        ));
1073        let http = MockHttp::new(rules);
1074        let mut client = authed_client(&http);
1075
1076        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1077        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1078    }
1079
1080    #[test]
1081    fn wav_url_is_none_until_the_render_is_ready() {
1082        let mut rules = auth_rules();
1083        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1084        let http = MockHttp::new(rules);
1085        let mut client = authed_client(&http);
1086
1087        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1088        assert_eq!(url, None);
1089    }
1090
1091    #[test]
1092    fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1093        // The per-id gap-fill path must not apply the listing's downloadability
1094        // filter: an infill ancestor and an upload root both survive, fetched one
1095        // `/api/clip/{id}` at a time.
1096        let p1 = serde_json::json!({
1097            "id": "p1", "title": "Infill Ancestor", "status": "complete",
1098            "metadata": {"type": "gen", "task": "infill"}
1099        })
1100        .to_string();
1101        let p2 = serde_json::json!({
1102            "id": "p2", "title": "Uploaded Root", "status": "complete",
1103            "metadata": {"type": "upload"}
1104        })
1105        .to_string();
1106        let mut rules = auth_rules();
1107        rules.push(Rule::new("/api/clip/p1", 200, p1));
1108        rules.push(Rule::new("/api/clip/p2", 200, p2));
1109        let http = MockHttp::new(rules);
1110        let mut client = authed_client(&http);
1111
1112        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1113        assert_eq!(
1114            clips.len(),
1115            2,
1116            "infill and upload ancestors must not be filtered"
1117        );
1118        assert_eq!(clips[0].id, "p1");
1119        assert_eq!(clips[1].id, "p2");
1120    }
1121
1122    #[test]
1123    fn get_clips_by_ids_returns_a_trashed_clip() {
1124        // A trashed ancestor must still be retrievable by id (the v2 `?ids=`
1125        // capability that per-id `/api/clip/{id}` replaces).
1126        let trashed = serde_json::json!({
1127            "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1128            "is_trashed": true, "metadata": {"type": "gen"}
1129        })
1130        .to_string();
1131        let mut rules = auth_rules();
1132        rules.push(Rule::new("/api/clip/t1", 200, trashed));
1133        let http = MockHttp::new(rules);
1134        let mut client = authed_client(&http);
1135
1136        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1137        assert_eq!(clips.len(), 1);
1138        assert_eq!(clips[0].id, "t1");
1139        assert!(clips[0].is_trashed);
1140    }
1141
1142    #[test]
1143    fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1144        let only = serde_json::json!({
1145            "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1146        })
1147        .to_string();
1148        let http = ScriptedHttp::new()
1149            .with_auth()
1150            .route("/api/clip/gone", Reply::status(404))
1151            .route("/api/clip/only", Reply::json(&only));
1152        let mut client = scripted_client(&http, RecordingClock::new());
1153
1154        let clips =
1155            pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1156        assert_eq!(clips.len(), 1, "the 404 id is skipped");
1157        assert_eq!(clips[0].id, "only");
1158        // "only" is fetched once despite appearing twice; "gone" is attempted once.
1159        assert_eq!(http.count("/api/clip/only"), 1);
1160        assert_eq!(http.count("/api/clip/gone"), 1);
1161    }
1162
1163    #[test]
1164    fn get_clip_parent_reads_the_parent_clip() {
1165        let parent = serde_json::json!({
1166            "id": "par", "title": "Ancestor", "status": "complete",
1167            "metadata": {"type": "gen"}
1168        })
1169        .to_string();
1170        let mut rules = auth_rules();
1171        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1172        let http = MockHttp::new(rules);
1173        let mut client = authed_client(&http);
1174
1175        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1176        assert_eq!(clip.unwrap().id, "par");
1177    }
1178
1179    #[test]
1180    fn get_clip_parent_is_none_for_a_root() {
1181        let mut rules = auth_rules();
1182        rules.push(Rule::new(
1183            "/api/clips/parent",
1184            404,
1185            r#"{"detail": "no parent"}"#.to_string(),
1186        ));
1187        let http = MockHttp::new(rules);
1188        let mut client = authed_client(&http);
1189
1190        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1191        assert!(clip.is_none());
1192    }
1193
1194    #[test]
1195    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1196        // A transient 5xx must never be mistaken for "this clip is a root":
1197        // folding it into Ok(None) would fabricate a wrong external root and let
1198        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
1199        for status in [500u16, 503] {
1200            let mut rules = auth_rules();
1201            rules.push(Rule::new(
1202                "/api/clips/parent",
1203                status,
1204                r#"{"detail": "server error"}"#.to_string(),
1205            ));
1206            let http = MockHttp::new(rules);
1207            let mut client = authed_client(&http);
1208
1209            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1210            assert!(
1211                matches!(result, Err(Error::Api(_))),
1212                "status {status} must propagate as an error, not Ok(None)"
1213            );
1214        }
1215    }
1216
1217    #[test]
1218    fn get_playlists_maps_entries_and_skips_missing_ids() {
1219        let page1 = serde_json::json!({
1220            "playlists": [
1221                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1222                {"id": "", "name": "No Id", "num_total_results": 3},
1223                {"name": "Also No Id"}
1224            ]
1225        })
1226        .to_string();
1227        let mut rules = auth_rules();
1228        // Page 1 returns entries; page 2 is empty, ending pagination.
1229        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1230        rules.push(Rule::new(
1231            "/api/playlist/me?page=2",
1232            200,
1233            r#"{"playlists": []}"#.to_string(),
1234        ));
1235        let http = MockHttp::new(rules);
1236        let mut client = authed_client(&http);
1237
1238        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1239        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1240        assert_eq!(
1241            playlists[0],
1242            Playlist {
1243                id: "pl1".to_owned(),
1244                name: "Road Trip".to_owned(),
1245                num_clips: 12,
1246            }
1247        );
1248    }
1249
1250    #[test]
1251    fn get_playlists_defaults_a_missing_name_to_untitled() {
1252        let page1 = serde_json::json!({
1253            "playlists": [{"id": "pl9", "num_total_results": 1}]
1254        })
1255        .to_string();
1256        let mut rules = auth_rules();
1257        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1258        rules.push(Rule::new(
1259            "/api/playlist/me?page=2",
1260            200,
1261            r#"{"playlists": []}"#.to_string(),
1262        ));
1263        let http = MockHttp::new(rules);
1264        let mut client = authed_client(&http);
1265
1266        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1267        assert_eq!(playlists[0].name, "Untitled");
1268    }
1269
1270    #[test]
1271    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1272        // Members arrive wrapped under `clip`, in playlist order, already
1273        // non-trashed. Order is preserved and no downloadability filter is applied.
1274        let body = serde_json::json!({
1275            "num_total_results": 2,
1276            "playlist_clips": [
1277                {"clip": {
1278                    "id": "second", "title": "Second", "status": "complete",
1279                    "metadata": {"duration": 60.0, "type": "gen"}
1280                }},
1281                {"clip": {
1282                    "id": "first", "title": "First", "status": "complete",
1283                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1284                }}
1285            ]
1286        })
1287        .to_string();
1288        let mut rules = auth_rules();
1289        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1290        let http = MockHttp::new(rules);
1291        let mut client = authed_client(&http);
1292
1293        let (clips, complete) =
1294            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1295        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1296        assert_eq!(clips[0].id, "second");
1297        assert_eq!(clips[1].id, "first");
1298        assert!(
1299            complete,
1300            "returned == num_total_results is fully enumerated"
1301        );
1302    }
1303
1304    #[test]
1305    fn get_playlist_clips_short_page_is_not_complete() {
1306        // A page with fewer entries than num_total_results is not authoritative.
1307        let body = serde_json::json!({
1308            "num_total_results": 5,
1309            "playlist_clips": [
1310                {"clip": {
1311                    "id": "only", "title": "Only", "status": "complete",
1312                    "metadata": {"duration": 60.0, "type": "gen"}
1313                }}
1314            ]
1315        })
1316        .to_string();
1317        let mut rules = auth_rules();
1318        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1319        let http = MockHttp::new(rules);
1320        let mut client = authed_client(&http);
1321
1322        let (clips, complete) =
1323            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1324        assert_eq!(clips.len(), 1);
1325        assert!(!complete, "a short page is not fully enumerated");
1326    }
1327
1328    #[test]
1329    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1330        let mut rules = auth_rules();
1331        rules.push(Rule::new(
1332            "/api/playlist/empty/",
1333            200,
1334            r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1335        ));
1336        let http = MockHttp::new(rules);
1337        let mut client = authed_client(&http);
1338
1339        let (clips, complete) =
1340            pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1341        assert!(clips.is_empty());
1342        assert!(
1343            complete,
1344            "an empty playlist reporting zero total is complete"
1345        );
1346    }
1347
1348    #[test]
1349    fn get_playlist_clips_missing_total_is_not_complete() {
1350        // A body without num_total_results cannot be verified as whole, so it is
1351        // never authoritative -- an empty or malformed page must not let a Mirror
1352        // area delete from it (D5).
1353        let mut rules = auth_rules();
1354        rules.push(Rule::new(
1355            "/api/playlist/pl1/",
1356            200,
1357            r#"{"playlist_clips": []}"#.to_string(),
1358        ));
1359        let http = MockHttp::new(rules);
1360        let mut client = authed_client(&http);
1361
1362        let (clips, complete) =
1363            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1364        assert!(clips.is_empty());
1365        assert!(!complete, "a missing total is never fully enumerated");
1366    }
1367}