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, HashMap};
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8use serde_json::Value;
9
10use crate::auth::ClerkAuth;
11use crate::backoff::{backoff_delay, retry_after};
12use crate::clock::Clock;
13use crate::consts::{
14    API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
15    FEED_V3_PATH, GET_SONGS_BY_IDS_PATH, GET_SONGS_CHUNK, MAX_PAGES, PLAYLIST_ME_PATH,
16    PLAYLIST_PATH, SUNO_API_BASE_URL,
17};
18use crate::error::{Error, Result};
19use crate::http::{Http, HttpRequest, Method};
20use crate::is_downloadable;
21use crate::limiter::{AdaptiveLimiter, retry_after_delay};
22use crate::lyrics::AlignedLyrics;
23use crate::model::Clip;
24
25/// One of the account's own playlists, as listed by `/api/playlist/me`.
26///
27/// Carries only what playlist reconciliation needs: the stable id (the state
28/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
29/// and the member count for reporting. The ordered members are fetched
30/// separately with [`SunoClient::get_playlist_clips`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Playlist {
33    /// The playlist's stable Suno id.
34    pub id: String,
35    /// The playlist's display name.
36    pub name: String,
37    /// The number of clips Suno reports in the playlist.
38    pub num_clips: u64,
39}
40
41/// The authenticated account's billing snapshot: credits, quota, account
42/// status, plan identity, and entitlements.
43///
44/// Every field is optional so a drifting payload never fails the parse; an
45/// absent field reads as "unknown", not zero. Numbers are signed because the
46/// API returns negatives (e.g. the `-1` sentinel), and `features` is a plain
47/// string set rather than an enum so new entitlement flags surface without a
48/// code change.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct BillingInfo {
51    /// Credits remaining in the current billing state.
52    pub total_credits_left: Option<i64>,
53    /// Monthly credit allotment (the quota denominator).
54    pub monthly_limit: Option<i64>,
55    /// Credits consumed this period (the quota numerator).
56    pub monthly_usage: Option<i64>,
57    /// Add-on, non-monthly credit balance.
58    pub credits: Option<i64>,
59    /// Billing period unit, e.g. `"month"`.
60    pub period: Option<String>,
61    /// Current period end (ISO8601), when usage resets.
62    pub period_end: Option<String>,
63    /// Next renewal (ISO8601).
64    pub renews_on: Option<String>,
65    /// Whether the subscription is active.
66    pub is_active: Option<bool>,
67    /// Whether the subscription is paused (paused subs stop refreshing credits).
68    pub is_paused: Option<bool>,
69    /// Whether payment is failing (credits may stop refreshing).
70    pub is_past_due: Option<bool>,
71    /// Whether the subscription is gifted.
72    pub is_gifted: Option<bool>,
73    /// Subscription platform, e.g. `"stripe"`.
74    pub subscription_platform: Option<String>,
75    /// Stable machine key for the plan tier, e.g. `"pro"`.
76    pub plan_key: Option<String>,
77    /// Human plan label, e.g. `"Pro Plan"`.
78    pub plan_name: Option<String>,
79    /// Plan tier rank (free 0, pro 10, premier 30).
80    pub plan_level: Option<i64>,
81    /// Entitlement flags, the union of `accessible_features[].name` and
82    /// `plan.usage_plan_features[].name`.
83    pub features: BTreeSet<String>,
84}
85
86impl BillingInfo {
87    /// Whether the account is entitled to the named feature.
88    pub fn has_feature(&self, name: &str) -> bool {
89        self.features.contains(name)
90    }
91
92    /// Whether the account may separate stems.
93    pub fn can_get_stems(&self) -> bool {
94        self.has_feature("get_stems")
95    }
96
97    /// Whether the account may convert audio to lossless.
98    pub fn can_convert_audio(&self) -> bool {
99        self.has_feature("convert_audio")
100    }
101}
102
103/// One separated stem of a clip, as listed by the free, read-only stems
104/// endpoint.
105///
106/// A stem is itself a full clip object: the listing returns the same shape as
107/// the library feed, so each stem carries its own clip `id`, a `title` whose
108/// trailing parenthetical is the stem label (e.g. `"My Song (Vocals)"`), a
109/// `status`, and a public `audio_url` on `cdn1.suno.ai` that downloads free and
110/// unauthenticated. Listing and downloading stems never spends credits or
111/// triggers separation.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Stem {
114    /// The stem's own server clip id. Used both as the stable per-stem key and
115    /// to render the stem's lossless WAV through the free `convert_wav` flow.
116    pub id: String,
117    /// The stem label, preferring the structured `metadata.stem_type_group_name`
118    /// (normalised, e.g. `Backing_Vocals` -> `Backing Vocals`) and falling back
119    /// to the trailing parenthetical of the stem clip's title. May be blank when
120    /// neither is present, so it is never used alone as a key or name.
121    pub label: String,
122    /// The public CDN MP3 URL the stem downloads from (a plain GET; free).
123    pub url: String,
124}
125
126/// A client for the Suno library API, owning the account's [`ClerkAuth`].
127///
128/// The [`Clock`] is held so [`api_request`](Self::api_request) can back off
129/// through the port on a `429` or transient failure — the engine still sleeps
130/// nowhere itself. The [`AdaptiveLimiter`] paces reactively: an unthrottled
131/// listing waits nowhere, and only after a `429` does it space requests out,
132/// halving the rate and ramping it back after a run of clean successes so pacing
133/// tracks Suno's real limit rather than a fixed constant.
134pub struct SunoClient<C> {
135    auth: ClerkAuth,
136    clock: C,
137    limiter: Mutex<AdaptiveLimiter>,
138}
139
140impl<C: Clock> SunoClient<C> {
141    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
142    pub fn new(auth: ClerkAuth, clock: C) -> Self {
143        Self {
144            auth,
145            clock,
146            limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
147        }
148    }
149
150    /// Borrow the underlying authenticator.
151    pub fn auth(&self) -> &ClerkAuth {
152        &self.auth
153    }
154
155    /// The adaptive limiter's current requests-per-second rate, for tests that
156    /// assert the limiter still records success and `429` correctly (including
157    /// under concurrent WAV-render calls serialised through the executor).
158    #[cfg(test)]
159    pub(crate) fn limiter_rate(&self) -> f64 {
160        self.limiter.lock().unwrap().rate()
161    }
162
163    /// List clips across the whole library, or only liked clips.
164    ///
165    /// Walks the cursor-paginated `POST /api/feed/v3` feed, following
166    /// `next_cursor` until the server reports the end. Once `limit` clips have
167    /// been collected it stops at the next page boundary and truncates to
168    /// `limit`. Paging is hard-capped at [`MAX_PAGES`] so a runaway
169    /// `has_more` can never loop forever. When `liked` is set the feed filter
170    /// scopes to liked clips (`liked: "True"`).
171    ///
172    /// Returns the clips paired with a `complete` flag that is `true` only when
173    /// paging ended because the server reported `has_more == false` (the feed
174    /// fully drained). A missing `has_more`, a `has_more == true` page with no
175    /// usable `next_cursor`, a `limit` stop, exhausting [`MAX_PAGES`], or any
176    /// transport error all yield `false` (or propagate) so the caller can refuse
177    /// to treat a truncated listing as authoritative for deletion.
178    pub async fn list_clips(
179        &self,
180        http: &impl Http,
181        liked: bool,
182        limit: Option<usize>,
183    ) -> Result<(Vec<Clip>, bool)> {
184        let mut clips = Vec::new();
185        let mut cursor: Option<String> = None;
186        let mut complete = false;
187        for _ in 0..MAX_PAGES {
188            let body = feed_v3_body(liked, cursor.as_deref());
189            let response = self
190                .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
191                .await?;
192            let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
193            clips.extend(page_clips);
194            match has_more {
195                Some(false) => {
196                    complete = true;
197                    break;
198                }
199                Some(true) => match next_cursor {
200                    Some(next) => cursor = Some(next),
201                    None => break,
202                },
203                None => break,
204            }
205            if limit.is_some_and(|n| clips.len() >= n) {
206                break;
207            }
208        }
209        if let Some(n) = limit {
210            clips.truncate(n);
211        }
212        Ok((clips, complete))
213    }
214
215    /// Fetch one clip by ID.
216    ///
217    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
218    /// scanning the library feed if that endpoint yields no matching clip.
219    pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
220        if let Some(clip) = self.try_get_clip(http, id).await? {
221            return Ok(clip);
222        }
223        self.find_in_feed(http, id).await
224    }
225
226    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
227    pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
228        let path = format!("/api/gen/{id}/convert_wav/");
229        self.api_request(http, Method::Post, &path, Vec::new())
230            .await?;
231        Ok(())
232    }
233
234    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
235    ///
236    /// A `404` maps to `None` (the render is absent, not yet requested, or the
237    /// endpoint has moved), symmetric with [`aligned_lyrics`](Self::aligned_lyrics)
238    /// so an unrendered clip is "no WAV yet" rather than a run-aborting error.
239    /// Like [`request_wav`](Self::request_wav) it skips the shared retry: the
240    /// caller's poll loop owns that budget.
241    pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
242        let path = format!("/api/gen/{id}/wav_file/");
243        let body = match self.api_get(http, &path).await {
244            Ok(body) => body,
245            Err(Error::NotFound(_)) => return Ok(None),
246            Err(err) => return Err(err),
247        };
248        let data: Value = serde_json::from_slice(&body)
249            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
250        Ok(data
251            .get("wav_file_url")
252            .and_then(Value::as_str)
253            .filter(|url| !url.is_empty())
254            .map(str::to_string))
255    }
256
257    /// Fetch a clip's word- and line-level aligned (synced) lyrics.
258    ///
259    /// `GET /api/gen/{id}/aligned_lyrics/v2/` (the trailing slash is required) on
260    /// the studio-api host, authenticated with the same JWT as every other
261    /// library read. The `v2` shape carries both a flat word-level list and a
262    /// line-level list with section labels and nested per-word timing (see
263    /// [`AlignedLyrics`]).
264    ///
265    /// An instrumental or un-alignable clip returns `200` with empty arrays,
266    /// which maps to an empty [`AlignedLyrics`]; a `404` (no alignment for the
267    /// clip) is treated the same way, so an absent endpoint is "no synced
268    /// lyrics" rather than a run failure — the caller then writes no synced
269    /// artefact, exactly as an empty cover URL writes no cover. Rides the
270    /// adaptive rate limiter like the other reads.
271    pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
272        let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
273        match self.api_get_retrying(http, &path).await {
274            Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
275            Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
276            Err(err) => Err(err),
277        }
278    }
279
280    /// Fetch specific clips by id, batch-first with a per-id fallback.
281    ///
282    /// Used by lineage resolution to gap-fill ancestors that are absent from a
283    /// normal listing, including trashed ones. Ids are fetched in a single
284    /// batch via [`get_songs_by_ids`](Self::get_songs_by_ids)
285    /// (`GET /api/clips/get_songs_by_ids`), which cuts the round-trips and `429`s
286    /// of one request per id. Any ids the batch does not return (individually
287    /// trashed or absent, exactly as a `/api/clip/{id}` `404` today, or in a
288    /// chunk the batch endpoint could not serve) then fall back to one
289    /// `GET /api/clip/{id}` each, with bounded `concurrency`, attempted exactly
290    /// once, and a `404` there is skipped so the caller can fall back to the
291    /// parent endpoint. A `429` while batching propagates rather than fanning
292    /// out into per-id requests.
293    ///
294    /// Unlike [`list_clips`](Self::list_clips), no downloadability filter is
295    /// applied: an ancestor may itself be an infill or context-window artefact
296    /// that the lineage walk must still traverse. Clips returned here are
297    /// ancestors for resolution only and must never be treated as download
298    /// candidates. Ids are deduplicated in order and the result preserves that
299    /// de-duplicated input order, matched by id (never by response position).
300    /// The signature is unchanged so [`gap_fill`](crate::lineage) is unaffected.
301    pub async fn get_clips_by_ids(
302        &self,
303        http: &impl Http,
304        ids: &[&str],
305        concurrency: usize,
306    ) -> Result<Vec<Clip>> {
307        let ordered = dedup_nonempty(ids);
308        let mut found: HashMap<&str, Clip> = self
309            .get_songs_by_ids(http, &ordered)
310            .await?
311            .into_iter()
312            .filter_map(|clip| {
313                ordered
314                    .iter()
315                    .find(|id| **id == clip.id)
316                    .map(|id| (*id, clip))
317            })
318            .collect();
319        let omitted: Vec<&str> = ordered
320            .iter()
321            .copied()
322            .filter(|id| !found.contains_key(id))
323            .collect();
324        if !omitted.is_empty() {
325            for clip in self
326                .fetch_clips_individually(http, &omitted, concurrency)
327                .await?
328            {
329                if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
330                    found.insert(id, clip);
331                }
332            }
333        }
334        Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
335    }
336
337    /// Batch-fetch clips by id via `GET /api/clips/get_songs_by_ids?ids=…&ids=…`.
338    ///
339    /// This is the pure batch primitive: the deduplicated ids are split into
340    /// chunks of [`GET_SONGS_CHUNK`], each requested with repeated `ids=` params,
341    /// and the `{"clips":[…]}` body is parsed defensively and matched back to the
342    /// requested ids by id, so the result preserves the de-duplicated input order
343    /// regardless of the server's ordering and drops any clip that was not asked
344    /// for. Ids the batch does not return (trashed, absent, or in a chunk the
345    /// endpoint could not serve) are simply left out; filling them is the
346    /// caller's job (see [`get_clips_by_ids`](Self::get_clips_by_ids)).
347    ///
348    /// The batch endpoint is undocumented and may be unavailable. A chunk that
349    /// the endpoint cannot serve (a `404`, a `400`, a `5xx`, a transport failure,
350    /// or a body that is not `{"clips":[…]}`) yields nothing for that chunk
351    /// rather than erroring, so an outage or reshape degrades rather than breaks
352    /// (the decoupling rule) and the caller's per-id fallback recovers those ids
353    /// exactly once. A `429`, by contrast, rides the retry inside
354    /// [`api_get_retrying`](Self::api_get_retrying) and, once exhausted,
355    /// propagates rather than letting a burst of per-id requests deepen the
356    /// throttling; an auth failure likewise propagates rather than being masked.
357    pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
358        let ordered = dedup_nonempty(ids);
359        let mut found: HashMap<&str, Clip> = HashMap::new();
360        for chunk in ordered.chunks(GET_SONGS_CHUNK) {
361            let query = chunk
362                .iter()
363                .map(|id| format!("ids={id}"))
364                .collect::<Vec<_>>()
365                .join("&");
366            let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
367            let clips = match self.api_get_retrying(http, &path).await {
368                Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
369                Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
370                Err(_) => Vec::new(),
371            };
372            for clip in clips {
373                if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
374                    found.insert(id, clip);
375                }
376            }
377        }
378        Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
379    }
380
381    /// Fetch clips one `GET /api/clip/{id}` per id, with bounded concurrency.
382    ///
383    /// The per-id fallback used by [`get_clips_by_ids`](Self::get_clips_by_ids)
384    /// for any ids the batch did not return, whether individually omitted or in a
385    /// whole chunk the batch endpoint could not serve. `/api/clip/{id}` returns
386    /// any clip, trashed or artefact, with the full field set and no
387    /// downloadability filter. An id that `404`s is skipped; the input order is
388    /// preserved.
389    async fn fetch_clips_individually(
390        &self,
391        http: &impl Http,
392        ids: &[&str],
393        concurrency: usize,
394    ) -> Result<Vec<Clip>> {
395        let limit = concurrency.max(1);
396        let fetched = stream::iter(ids.iter().copied())
397            .map(|id| async move {
398                let path = format!("/api/clip/{id}");
399                match self.api_get_retrying(http, &path).await {
400                    Ok(body) => Ok(parse_clip(&body)),
401                    Err(Error::NotFound(_)) => Ok(None),
402                    Err(err) => Err(err),
403                }
404            })
405            .buffered(limit)
406            .collect::<Vec<_>>()
407            .await;
408        let mut clips = Vec::new();
409        for item in fetched {
410            if let Some(clip) = item? {
411                clips.push(clip);
412            }
413        }
414        Ok(clips)
415    }
416
417    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
418    ///
419    /// Returns the parent clip, or `None` when the clip is a root. A root's
420    /// parent is reported as HTTP `200` with a bodiless clip that carries no
421    /// `id` (e.g. `{"is_public": false}`), not a `404`: [`parse_clip`] requires
422    /// a non-empty id, so that root shape maps to `Ok(None)` here. The `404`
423    /// arm is kept as a belt-and-braces fallback for the alternative "no parent"
424    /// encoding. Any other failure, including a transient `5xx`, propagates as
425    /// an error rather than being mistaken for a root.
426    pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
427        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
428        match self.api_get_retrying(http, &path).await {
429            // A root replies 200 with no id; parse_clip gates on a non-empty id
430            // and yields None, so a root never looks like a fetched parent.
431            Ok(body) => Ok(parse_clip(&body)),
432            Err(Error::NotFound(_)) => Ok(None),
433            Err(err) => Err(err),
434        }
435    }
436
437    /// List the account's own playlists, paging `/api/playlist/me`.
438    ///
439    /// Trashed and share-list playlists are excluded by query, so the result is
440    /// the account's authoritative own set. Paging stops on the first empty page
441    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
442    /// parameter cannot loop forever. Only entries with a non-empty id are kept,
443    /// and accumulated entries are de-duplicated by id so a server that ignores
444    /// the page parameter and repeats a body cannot inflate the set.
445    ///
446    /// A hard failure propagates as an error; the caller treats that as "the
447    /// playlist listing did not fully enumerate" and refuses every playlist
448    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
449    pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
450        let mut playlists = Vec::new();
451        let mut seen = BTreeSet::new();
452        for page in 1..=MAX_PAGES {
453            let path =
454                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
455            let body = self.api_get_retrying(http, &path).await?;
456            let page_playlists = parse_playlists(&body)?;
457            if page_playlists.is_empty() {
458                break;
459            }
460            for playlist in page_playlists {
461                if seen.insert(playlist.id.clone()) {
462                    playlists.push(playlist);
463                }
464            }
465        }
466        Ok(playlists)
467    }
468
469    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
470    ///
471    /// The response's `playlist_clips[]` is already ordered and trashed members
472    /// are excluded by Suno, so the order is preserved exactly and no
473    /// downloadability filter is applied: a playlist may legitimately contain any
474    /// clip. Each entry's `clip` object is mapped (falling back to the entry
475    /// itself), and only clips with a non-empty id are kept.
476    ///
477    /// The returned `bool` is a completeness signal for deletion authority: the
478    /// endpoint reports `num_total_results` (the playlist's full member count)
479    /// alongside `playlist_clips[]`, so `true` means every member came back on
480    /// this single page intact (`num_total_results` present, equal to the raw
481    /// count, and no member dropped for a missing/empty id). A short page, or one
482    /// missing a member's id, returns `false`, so a Mirror playlist area under
483    /// `library = "off"` is never treated as authoritative unless its whole
484    /// member set was seen (D5).
485    pub async fn get_playlist_clips(
486        &self,
487        http: &impl Http,
488        id: &str,
489    ) -> Result<(Vec<Clip>, bool)> {
490        let path = format!("{PLAYLIST_PATH}{id}/");
491        let body = self.api_get_retrying(http, &path).await?;
492        parse_playlist_clips(&body)
493    }
494
495    /// Read the authenticated account's billing information.
496    pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
497        let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
498        parse_billing_info(&body)
499    }
500
501    /// List a clip's already-separated stems (free, read-only).
502    ///
503    /// Uses the live stems shape: first `GET /api/clip/{id}/stems/pages` for the
504    /// page count (`{"pages": N}`), then `GET /api/clip/{id}/stems?page=P` for
505    /// each `P` in `0..N` (the pages are 0-indexed), whose body is
506    /// `{"stems": [<clip>, ...]}` where each stem is a full clip object. Every
507    /// request rides the shared limiter and retry. This endpoint only reads: it
508    /// never spends credits and never triggers separation, so it is safe on the
509    /// bulk mirror path. The caller must only invoke it when the clip's
510    /// `has_stem` is true.
511    ///
512    /// Returns the collected stems paired with a `complete` flag that is `true`
513    /// only when the listing was fully and authoritatively enumerated: the page
514    /// count came back and every one of its pages drained, AFTER at least one
515    /// stem was seen. This encodes the deletion-safety invariant: an empty
516    /// listing (`pages == 0`, or a `400`/`404` on the page-count endpoint, which
517    /// Suno returns for a clip with zero stems), a transport failure, or a
518    /// partial drain (a page error mid-enumeration surfaces as `Err`) all yield a
519    /// non-authoritative result, so the caller KEEPS any existing local stems and
520    /// never reads the absence as "no stems". A clip that declares more than
521    /// [`MAX_PAGES`] pages is likewise a truncated listing and never authoritative.
522    /// A stem is only ever removed from an authoritative (`complete`) listing that
523    /// omits it, or when its owning clip's audio is deleted.
524    pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
525        let declared = self.stem_page_count(http, clip_id).await?;
526        // Zero pages (or no page count) is Suno's "this clip has no stems"
527        // answer: indeterminate for deletion, never an authoritative empty.
528        if declared == 0 {
529            return Ok((Vec::new(), false));
530        }
531        let pages = declared.min(MAX_PAGES);
532        let mut stems: Vec<Stem> = Vec::new();
533        for page in 0..pages {
534            // Pages are 0-indexed (0..N-1); note the path has no trailing slash
535            // before the query, distinguishing it from `.../stems/pages`.
536            let path = format!("/api/clip/{clip_id}/stems?page={page}");
537            // A page error mid-enumeration is indeterminate, not a clean end:
538            // surface it so the caller keeps existing stems rather than reading a
539            // partial drain as authoritative and removing stems.
540            let body = self.api_get_retrying(http, &path).await?;
541            stems.extend(parse_stems_page(&body));
542        }
543        dedupe_stems(&mut stems);
544        // Authoritative only when the whole declared page set actually drained
545        // and it held stems: an all-empty listing is never "no stems", and a
546        // clip declaring more than the `MAX_PAGES` cap is a truncated listing,
547        // never authoritative, so its un-fetched stems are kept (mirroring the
548        // feed's `list_clips` cap handling).
549        let complete = !stems.is_empty() && declared <= MAX_PAGES;
550        Ok((stems, complete))
551    }
552
553    /// Read the stems page count for a clip from `GET /api/clip/{id}/stems/pages`
554    /// (`{"pages": N}`).
555    ///
556    /// A clip with no stems answers `400`/`404` here; both mean "no stems" and
557    /// map to `0` (indeterminate, never an authoritative empty set), while any
558    /// other error (a transient `5xx`, a transport failure) propagates so the
559    /// caller treats the stems as unknown and keeps them.
560    async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
561        let path = format!("/api/clip/{clip_id}/stems/pages");
562        match self.api_get_retrying(http, &path).await {
563            Ok(body) => Ok(parse_stem_page_count(&body)),
564            Err(err) if is_invalid_page_error(&err) => Ok(0),
565            Err(Error::NotFound(_)) => Ok(0),
566            Err(err) => Err(err),
567        }
568    }
569
570    /// Try the dedicated clip endpoint, returning `None` when it is missing or
571    /// returns a body that does not yield the requested clip.
572    async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
573        let path = format!("/api/clip/{id}");
574        match self.api_get_retrying(http, &path).await {
575            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
576            Err(Error::NotFound(_)) => Ok(None),
577            Err(err) => Err(err),
578        }
579    }
580
581    /// Locate a clip by scanning the library feed.
582    async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
583        let (clips, _complete) = self.list_clips(http, false, None).await?;
584        clips
585            .into_iter()
586            .find(|clip| clip.id == id)
587            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
588    }
589
590    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
591    async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
592        self.api_request(http, Method::Get, path, Vec::new()).await
593    }
594
595    /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
596    async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
597        self.api_send_retrying(http, Method::Get, path, Vec::new())
598            .await
599    }
600
601    /// Like [`api_request`](Self::api_request) but rides through Suno's rate
602    /// limiter, pacing each request to the adaptive rate and backing off through
603    /// the [`Clock`] on a `429` (honouring `Retry-After` when present, defaulting
604    /// to 5s and capped at 60s) or a transient connection failure, up to
605    /// [`API_MAX_RETRIES`] times. Each attempt reconstructs the full request
606    /// (method, path, and body), so a throttled feed page re-POSTs the same
607    /// cursor rather than skipping ahead.
608    ///
609    /// Pacing lives here, at the single per-request layer, rather than in any
610    /// paged walk, so it composes with whatever listing calls it: a page or a
611    /// cursor walk pace identically. The [`AdaptiveLimiter`] paces reactively:
612    /// an unthrottled walk waits nowhere, and only after the first `429` does it
613    /// reserve shared request slots so concurrent callers are spaced in aggregate
614    /// at `1/rate`, widening that spacing as the rate is halved again.
615    ///
616    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
617    /// the executor owns that retry so its budget and poll interval stay in one
618    /// place. Library, playlist, and lineage reads use this so a full-library
619    /// walk is not aborted by a single throttled page.
620    async fn api_send_retrying(
621        &self,
622        http: &impl Http,
623        method: Method,
624        path: &str,
625        body: Vec<u8>,
626    ) -> Result<Vec<u8>> {
627        let pace = self.limiter.lock().unwrap().pace(Instant::now());
628        if !pace.is_zero() {
629            self.clock.sleep(pace).await;
630        }
631        let mut retries = 0;
632        loop {
633            match self.api_request(http, method, path, body.clone()).await {
634                Ok(response) => return Ok(response),
635                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
636                    self.clock.sleep(retry_after_delay(retry_after)).await;
637                    retries += 1;
638                }
639                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
640                    self.clock.sleep(backoff_delay(retries, None)).await;
641                    retries += 1;
642                }
643                Err(err) => return Err(err),
644            }
645        }
646    }
647
648    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
649    ///
650    /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
651    /// POST reaches the network unchanged.
652    async fn api_request(
653        &self,
654        http: &impl Http,
655        method: Method,
656        path: &str,
657        body: Vec<u8>,
658    ) -> Result<Vec<u8>> {
659        // Crate-wide POST allow-list. Every mutating Suno API request funnels
660        // through here, so refusing any POST to a path outside the known-safe
661        // set means a destructive or credit-spending endpoint can never be sent,
662        // even by a future edit that forgets the invariant. GETs are free and
663        // unrestricted; only POSTs are gated.
664        if method == Method::Post && !post_path_allowed(path) {
665            return Err(Error::Refused(format!(
666                "POST to {path} is not on the allow-list"
667            )));
668        }
669        let url = format!("{SUNO_API_BASE_URL}{path}");
670        let mut auth_refreshed = false;
671        loop {
672            let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
673            let mut request = match method {
674                Method::Get => HttpRequest::get(url.clone()),
675                Method::Post => HttpRequest::post(url.clone(), body.clone()),
676            };
677            request
678                .headers
679                .push(("Authorization".to_string(), format!("Bearer {jwt}")));
680            let response = http
681                .send(request)
682                .await
683                .map_err(|err| Error::Connection(err.to_string()))?;
684            match response.status {
685                200..=299 => {
686                    self.limiter.lock().unwrap().on_success();
687                    return Ok(response.body);
688                }
689                401 | 403 if !auth_refreshed => {
690                    self.auth.invalidate_jwt();
691                    auth_refreshed = true;
692                }
693                401 | 403 => {
694                    return Err(Error::Auth(format!(
695                        "Suno API auth failed with status {}",
696                        response.status
697                    )));
698                }
699                429 => {
700                    self.limiter.lock().unwrap().on_rate_limit();
701                    return Err(Error::RateLimited {
702                        retry_after: retry_after(&response),
703                    });
704                }
705                400 => {
706                    let preview: String = String::from_utf8_lossy(&response.body)
707                        .chars()
708                        .take(200)
709                        .collect();
710                    return Err(Error::BadRequest(format!(
711                        "Suno API returned 400: {preview}"
712                    )));
713                }
714                404 => {
715                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
716                }
717                status => {
718                    let preview: String = String::from_utf8_lossy(&response.body)
719                        .chars()
720                        .take(200)
721                        .collect();
722                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
723                }
724            }
725        }
726    }
727}
728
729/// Unwrap a `{ "clip": {...} }` wrapper to the inner clip object, or return
730/// `value` unchanged when it carries no object `clip` key (it is already bare).
731fn unwrap_clip(value: &Value) -> &Value {
732    value
733        .get("clip")
734        .filter(|clip| clip.is_object())
735        .unwrap_or(value)
736}
737
738/// Whether a Suno API path may be the target of a POST (the crate-wide POST
739/// allow-list). Membership is deliberately narrow so a mutating request is only
740/// ever sent to a vetted endpoint:
741///
742/// - [`FEED_V3_PATH`] — the cursor-paginated library listing (a POST by design).
743/// - `…/convert_wav/` — the per-clip server-side lossless WAV render.
744///
745/// A GET is never gated (reads are free and non-mutating). Any credit-spending
746/// generation endpoint is deliberately absent here.
747fn post_path_allowed(path: &str) -> bool {
748    if path == FEED_V3_PATH {
749        return true;
750    }
751    // The per-clip WAV render: /api/gen/{id}/convert_wav/ with a single id.
752    if let Some(rest) = path.strip_prefix("/api/gen/")
753        && let Some(id) = rest.strip_suffix("/convert_wav/")
754    {
755        return is_single_id_segment(id);
756    }
757    false
758}
759
760/// Whether `segment` is a single, non-empty path id segment: no slash, no query,
761/// and no `..` traversal, so an allow-list match can never be smuggled past by a
762/// crafted path.
763fn is_single_id_segment(segment: &str) -> bool {
764    !segment.is_empty()
765        && !segment.contains('/')
766        && !segment.contains('?')
767        && !segment.contains("..")
768}
769
770/// Whether an error is Suno's "this clip has no stems" answer on the stems
771/// page-count endpoint: a `400` (it returns `400 "Invalid page number"` for a
772/// clip with zero stems). Distinguished from a transient `5xx` (also
773/// [`Error::Api`]) so a server error is never mistaken for "no stems".
774fn is_invalid_page_error(err: &Error) -> bool {
775    matches!(err, Error::BadRequest(_))
776}
777
778/// Parse the stems page count from `GET /api/clip/{id}/stems/pages`
779/// (`{"pages": N}`).
780///
781/// A missing, non-numeric, or negative `pages` reads as `0` (no stems), so a
782/// malformed body is treated as indeterminate rather than guessing a count.
783fn parse_stem_page_count(body: &[u8]) -> u32 {
784    serde_json::from_slice::<Value>(body)
785        .ok()
786        .and_then(|data| data.get("pages").and_then(Value::as_u64))
787        .and_then(|pages| u32::try_from(pages).ok())
788        .unwrap_or(0)
789}
790
791/// Parse one page of the stems listing (`{"stems": [<clip>, ...]}`) into
792/// [`Stem`]s.
793///
794/// Each stem is a full clip object, so it is mapped with [`Clip::from_json`]:
795/// the id is the stem clip id, the label is the trailing parenthetical of its
796/// title, and the download URL is its public CDN MP3. Only stems carrying both a
797/// non-empty id and URL are kept — a stem with no id cannot be WAV-rendered, and
798/// one with no URL cannot be mirrored. Malformed JSON yields no stems (never a
799/// panic), so a bad body is treated as an empty, non-authoritative page.
800fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
801    let Ok(data) = serde_json::from_slice::<Value>(body) else {
802        return Vec::new();
803    };
804    let items = if let Some(array) = data.as_array() {
805        array.as_slice()
806    } else {
807        data.get("stems")
808            .and_then(Value::as_array)
809            .map(Vec::as_slice)
810            .unwrap_or(&[])
811    };
812    items
813        .iter()
814        .map(parse_stem)
815        .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
816        .collect()
817}
818
819/// Map one raw stem clip element to a [`Stem`]: its clip id, its stem label,
820/// and its public CDN MP3 URL.
821fn parse_stem(raw: &Value) -> Stem {
822    let clip = Clip::from_json(raw);
823    Stem {
824        id: clip.id.clone(),
825        label: stem_label(&clip),
826        url: clip.mp3_url(),
827    }
828}
829
830/// The stem's label, preferring the structured `metadata.stem_type_group_name`
831/// (normalised from its underscore form, `Backing_Vocals` -> `Backing Vocals`)
832/// over the fragile trailing title parenthetical, and empty when neither is
833/// present so the caller falls back to the stem id for naming.
834fn stem_label(clip: &Clip) -> String {
835    let group = clip.stem_type_group_name.replace('_', " ");
836    let group = group.trim();
837    if !group.is_empty() {
838        return group.to_string();
839    }
840    stem_label_from_title(&clip.title)
841}
842
843/// The stem label carried in a stem clip's title: the text inside its trailing
844/// parenthetical (`"My Song (Backing Vocals)"` -> `Backing Vocals`). Returns an
845/// empty string when the title has no closing parenthetical, so the caller falls
846/// back to the stem id for naming.
847fn stem_label_from_title(title: &str) -> String {
848    let trimmed = title.trim_end();
849    let Some(before_close) = trimmed.strip_suffix(')') else {
850        return String::new();
851    };
852    match before_close.rfind('(') {
853        Some(open) => before_close[open + 1..].trim().to_string(),
854        None => String::new(),
855    }
856}
857
858/// Drop stems that repeat across pages, keeping the first occurrence of each
859/// download URL so a paged listing counts a stem once.
860fn dedupe_stems(stems: &mut Vec<Stem>) {
861    let mut seen = BTreeSet::new();
862    stems.retain(|stem| seen.insert(stem.url.clone()));
863}
864
865/// Parse a single-clip response body, accepting either a bare clip object or a
866/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
867fn parse_clip(body: &[u8]) -> Option<Clip> {
868    let data: Value = serde_json::from_slice(body).ok()?;
869    let raw = unwrap_clip(&data);
870    let has_id = raw
871        .get("id")
872        .and_then(Value::as_str)
873        .is_some_and(|id| !id.is_empty());
874    has_id.then(|| Clip::from_json(raw))
875}
876
877/// Deduplicate ids in first-seen order, dropping empties. Shared by the by-id
878/// fetch paths so the batch, the fallback, and the returned order all agree.
879fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
880    let mut seen: BTreeSet<&str> = BTreeSet::new();
881    ids.iter()
882        .copied()
883        .filter(|id| !id.is_empty() && seen.insert(id))
884        .collect()
885}
886
887/// Parse a `get_songs_by_ids` `{"clips":[…]}` body into clips with a non-empty
888/// id. Returns `None` when the body is not valid JSON or lacks a `clips` array,
889/// signalling the caller to fall back to per-id fetches. No downloadability
890/// filter is applied: these are lineage ancestors, which may be artefacts.
891fn parse_songs_batch(body: &[u8]) -> Option<Vec<Clip>> {
892    let data: Value = serde_json::from_slice(body).ok()?;
893    let clips = data.get("clips")?.as_array()?;
894    Some(
895        clips
896            .iter()
897            .map(Clip::from_json)
898            .filter(|clip| !clip.id.is_empty())
899            .collect(),
900    )
901}
902
903/// Parse `/api/billing/info/` into the billing snapshot we report in `doctor`.
904///
905/// Only genuinely invalid JSON bytes fail; any valid JSON value (even a
906/// non-object such as `null` or `[]`) degrades to [`BillingInfo::default`].
907fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
908    let data: Value = serde_json::from_slice(body)
909        .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
910    Ok(from_billing_json(&data))
911}
912
913/// Map the raw billing JSON into the domain [`BillingInfo`].
914///
915/// Reads each field independently through `.get()`, defaulting to `None`/empty
916/// on a missing key or type mismatch, and never fails on a single field.
917/// `features` is the union of `accessible_features[].name` and
918/// `plan.usage_plan_features[].name`.
919fn from_billing_json(data: &Value) -> BillingInfo {
920    let plan = data.get("plan");
921    let mut features = BTreeSet::new();
922    collect_feature_names(data.get("accessible_features"), &mut features);
923    collect_feature_names(
924        plan.and_then(|plan| plan.get("usage_plan_features")),
925        &mut features,
926    );
927    BillingInfo {
928        total_credits_left: data.get("total_credits_left").and_then(json_i64),
929        monthly_limit: data.get("monthly_limit").and_then(json_i64),
930        monthly_usage: data.get("monthly_usage").and_then(json_i64),
931        credits: data.get("credits").and_then(json_i64),
932        period: json_string(data.get("period")),
933        period_end: json_string(data.get("period_end")),
934        renews_on: json_string(data.get("renews_on")),
935        is_active: data.get("is_active").and_then(Value::as_bool),
936        is_paused: data.get("is_paused").and_then(Value::as_bool),
937        is_past_due: data.get("is_past_due").and_then(Value::as_bool),
938        is_gifted: data.get("is_gifted").and_then(Value::as_bool),
939        subscription_platform: json_string(data.get("subscription_platform")),
940        plan_key: json_string(plan.and_then(|plan| plan.get("plan_key"))),
941        plan_name: json_string(plan.and_then(|plan| plan.get("name"))),
942        plan_level: plan.and_then(|plan| plan.get("level")).and_then(json_i64),
943        features,
944    }
945}
946
947/// Add the `name` of each `{ "name": ... }` element of a feature array to
948/// `out`, skipping non-arrays, non-object elements, and empty or missing names.
949fn collect_feature_names(array: Option<&Value>, out: &mut BTreeSet<String>) {
950    let Some(items) = array.and_then(Value::as_array) else {
951        return;
952    };
953    for name in items
954        .iter()
955        .filter_map(|item| item.get("name").and_then(Value::as_str))
956    {
957        if !name.is_empty() {
958            out.insert(name.to_owned());
959        }
960    }
961}
962
963/// Read an optional string field, cloning the value when present.
964fn json_string(value: Option<&Value>) -> Option<String> {
965    value.and_then(Value::as_str).map(str::to_owned)
966}
967
968/// Read a signed integer that Suno may encode as a JSON integer, an integral
969/// JSON float (`2450.0`), or a decimal string (`"2450"` or `"2450.0"`).
970///
971/// Non-integral values (`2450.5`), overflow, and junk yield `None`. The
972/// conversion is lossless and never saturates a value into range.
973fn json_i64(value: &Value) -> Option<i64> {
974    match value {
975        Value::Number(number) => number
976            .as_i64()
977            .or_else(|| number.as_f64().and_then(f64_to_i64)),
978        Value::String(text) => str_to_i64(text),
979        _ => None,
980    }
981}
982
983/// Convert a finite, integral `f64` to `i64`, rejecting fractional values and
984/// anything outside the exactly representable range.
985fn f64_to_i64(value: f64) -> Option<i64> {
986    // Beyond 2^53 an f64 cannot losslessly represent an integer: serde has
987    // already rounded (or saturated) such a value before we see it, so we
988    // refuse rather than return a wrong result. Below 2^53 the cast is exact.
989    if value.is_finite() && value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0 {
990        Some(value as i64)
991    } else {
992        None
993    }
994}
995
996/// Parse a decimal string into `i64`, accepting an all-zero fractional part
997/// (`"2450.0"`) but rejecting non-integral values, overflow, and junk.
998fn str_to_i64(text: &str) -> Option<i64> {
999    match text.split_once('.') {
1000        Some((integer, fraction)) => {
1001            let integral = fraction.is_empty() || fraction.bytes().all(|byte| byte == b'0');
1002            integral.then(|| integer.parse().ok()).flatten()
1003        }
1004        None => text.parse().ok(),
1005    }
1006}
1007
1008/// Build the JSON body for a `POST /api/feed/v3` page.
1009///
1010/// `filters.trashed` is the string `"False"` so the feed excludes trashed clips
1011/// exactly as the old v2 listing did; a `liked` walk adds `filters.liked =
1012/// "True"` (v3 ignores an `is_liked` key). The `cursor` is omitted on the first
1013/// page and set to the previous page's `next_cursor` thereafter.
1014fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
1015    let mut filters = serde_json::Map::new();
1016    filters.insert("trashed".to_string(), Value::String("False".to_string()));
1017    if liked {
1018        filters.insert("liked".to_string(), Value::String("True".to_string()));
1019    }
1020    let mut body = serde_json::Map::new();
1021    body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
1022    body.insert("filters".to_string(), Value::Object(filters));
1023    if let Some(cursor) = cursor {
1024        body.insert("cursor".to_string(), Value::String(cursor.to_string()));
1025    }
1026    serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
1027}
1028
1029/// Parse a v3 feed page into the kept clips, the raw `has_more`, and the
1030/// `next_cursor`.
1031///
1032/// `has_more` is [`None`] when the key is missing or not a bool, so the caller
1033/// can refuse to treat an unrecognised page as a fully drained feed. An empty
1034/// `next_cursor` string maps to [`None`] so it is never re-sent as a cursor.
1035fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
1036    let data: Value = serde_json::from_slice(body)
1037        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
1038    let Some(object) = data.as_object() else {
1039        return Ok((Vec::new(), None, None));
1040    };
1041    let clips = object
1042        .get("clips")
1043        .and_then(Value::as_array)
1044        .map(|raw| {
1045            raw.iter()
1046                .map(Clip::from_json)
1047                .filter(is_downloadable)
1048                .collect()
1049        })
1050        .unwrap_or_default();
1051    let has_more = object.get("has_more").and_then(Value::as_bool);
1052    let next_cursor = object
1053        .get("next_cursor")
1054        .and_then(Value::as_str)
1055        .filter(|cursor| !cursor.is_empty())
1056        .map(str::to_string);
1057    Ok((clips, has_more, next_cursor))
1058}
1059
1060/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
1061fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
1062    let data: Value = serde_json::from_slice(body)
1063        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1064    Ok(data
1065        .get("playlists")
1066        .and_then(Value::as_array)
1067        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
1068        .unwrap_or_default())
1069}
1070
1071/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
1072///
1073/// `num_total_results` is the playlist's member count; a missing name defaults
1074/// to `Untitled` (matching the clip mapping) so the file name is never empty.
1075fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
1076    let id = raw
1077        .get("id")
1078        .and_then(Value::as_str)
1079        .filter(|id| !id.is_empty())?
1080        .to_string();
1081    let name = match raw.get("name") {
1082        Some(Value::String(name)) if !name.is_empty() => name.clone(),
1083        _ => "Untitled".to_string(),
1084    };
1085    let num_clips = raw
1086        .get("num_total_results")
1087        .and_then(Value::as_u64)
1088        .unwrap_or(0);
1089    Some(Playlist {
1090        id,
1091        name,
1092        num_clips,
1093    })
1094}
1095
1096/// Parse a `/api/playlist/{id}/` body into its ordered member clips plus a
1097/// completeness flag.
1098///
1099/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
1100/// unwrapped (falling back to the entry itself), order is preserved exactly, and
1101/// only clips with a non-empty id survive. No downloadability filter is applied:
1102/// a playlist may hold any clip, and members absent from the local library are
1103/// reconciled as comment lines by the caller, not dropped here. The scoped-sync
1104/// path applies [`is_downloadable`](crate::is_downloadable) itself when it fetches
1105/// members as download candidates.
1106///
1107/// The completeness flag is `true` only when the response's `num_total_results`
1108/// is present, equals the raw `playlist_clips[]` count, and no member was
1109/// dropped by the empty-id filter, i.e. the whole member set arrived intact on
1110/// this single page. It gates a Mirror playlist area's deletion authority (D5):
1111/// a short or paginated page, or one carrying a member with a missing/empty
1112/// clip id, cannot be authoritative for deletion, so it returns `false`.
1113fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
1114    let data: Value = serde_json::from_slice(body)
1115        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1116    let raw = data.get("playlist_clips").and_then(Value::as_array);
1117    let raw_len = raw.map(|a| a.len()).unwrap_or(0);
1118    let clips: Vec<Clip> = raw
1119        .map(|raw| {
1120            raw.iter()
1121                .map(|entry| Clip::from_json(unwrap_clip(entry)))
1122                .filter(|clip| !clip.id.is_empty())
1123                .collect()
1124        })
1125        .unwrap_or_default();
1126    // Completeness requires the reported total to be present and to match the
1127    // raw entry count (before the empty-id filter) AND no member to have been
1128    // dropped by that filter (`clips.len() == raw_len`). A missing or malformed
1129    // total, a short page, or a single dropped member (empty/missing clip id)
1130    // all fail safe toward "not authoritative", so a Mirror area can never
1131    // delete from a page whose whole member set was not seen intact.
1132    let complete = data
1133        .get("num_total_results")
1134        .and_then(Value::as_u64)
1135        .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
1136    Ok((clips, complete))
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
1143    use std::time::Duration;
1144
1145    fn feed_body() -> String {
1146        serde_json::json!({
1147            "has_more": false,
1148            "clips": [
1149                {
1150                    "id": "a", "title": "Song A", "status": "complete",
1151                    "audio_url": "https://cdn1.suno.ai/a.mp3",
1152                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
1153                },
1154                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
1155                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
1156                {
1157                    "id": "d", "title": "Context", "status": "complete",
1158                    "metadata": {"type": "rendered_context_window"}
1159                }
1160            ]
1161        })
1162        .to_string()
1163    }
1164
1165    #[test]
1166    fn parse_feed_v3_filters_and_reads_pagination() {
1167        let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
1168        assert_eq!(has_more, Some(false));
1169        assert_eq!(next_cursor, None);
1170        assert_eq!(clips.len(), 1);
1171        assert_eq!(clips[0].id, "a");
1172        assert_eq!(clips[0].tags, "rock");
1173        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
1174    }
1175
1176    /// One real anonymised `POST /api/feed/v3` page (issue #219): a single
1177    /// downloadable clip carrying `media_urls`, `user_id`, `batch_index`, cdn2
1178    /// artwork, and a pagination envelope with `has_more`/`next_cursor`.
1179    const FEED_V3_PAGE: &str = r#"{
1180      "clips": [
1181        {
1182          "status": "complete",
1183          "title": "Track 31",
1184          "id": "00000000-0000-4000-8000-000000000076",
1185          "entity_type": "song_schema",
1186          "video_url": "",
1187          "audio_url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1188          "media_urls": [
1189            {
1190              "url": "https://media.cloudfront.net/1/clip/00000000-0000-4000-8000-000000000076.m4a",
1191              "content_type": "m4a-opus",
1192              "delivery": "progressive",
1193              "encoding": "1.0.0"
1194            },
1195            {
1196              "url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1197              "content_type": "mp3",
1198              "delivery": "progressive"
1199            }
1200          ],
1201          "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg",
1202          "image_large_url": "https://cdn2.suno.ai/image_large_00000000-0000-4000-8000-000000000076.jpeg",
1203          "major_model_version": "v4.5",
1204          "model_name": "chirp-ahi",
1205          "metadata": {
1206            "tags": "",
1207            "type": "gen",
1208            "duration": 272.0,
1209            "task": "gen_stem",
1210            "has_stem": false
1211          },
1212          "is_liked": false,
1213          "user_id": "00000000-0000-4000-8000-000000000019",
1214          "display_name": "Example Artist 4",
1215          "handle": "example-artist-1",
1216          "is_trashed": false,
1217          "is_hidden": false,
1218          "created_at": "2026-07-03T13:15:10.635Z",
1219          "is_public": false,
1220          "explicit": false,
1221          "batch_index": 23,
1222          "clip_roots": {
1223            "clips": [
1224              {
1225                "id": "00000000-0000-4000-8000-000000000028",
1226                "title": "Track 7",
1227                "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000028.jpeg",
1228                "is_public": false,
1229                "user_display_name": "Example Artist 4",
1230                "user_handle": "example-artist-1",
1231                "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
1232              }
1233            ],
1234            "clip_attribution_type": "remix"
1235          }
1236        }
1237      ],
1238      "has_more": true,
1239      "next_cursor": "cursor-token"
1240    }"#;
1241
1242    #[test]
1243    fn parse_feed_v3_page_maps_real_body_and_pagination() {
1244        let (clips, has_more, next_cursor) = parse_feed_v3(FEED_V3_PAGE.as_bytes()).unwrap();
1245        assert_eq!(has_more, Some(true));
1246        assert_eq!(next_cursor.as_deref(), Some("cursor-token"));
1247        // The single gen_stem clip is complete and passes is_downloadable.
1248        assert_eq!(clips.len(), 1);
1249        let clip = &clips[0];
1250        assert_eq!(clip.id, "00000000-0000-4000-8000-000000000076");
1251        assert_eq!(clip.title, "Track 31");
1252        assert_eq!(clip.model_name, "chirp-ahi");
1253        assert_eq!(clip.major_model_version, "v4.5");
1254        assert_eq!(clip.user_id, "00000000-0000-4000-8000-000000000019");
1255        assert_eq!(clip.batch_index, Some(23));
1256        // The cdn2 artwork host is rewritten to cdn1.
1257        assert_eq!(
1258            clip.image_url,
1259            "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg"
1260        );
1261        assert!(clip.image_large_url.starts_with("https://cdn1.suno.ai/"));
1262        // media_urls carries both assets; mp3_url prefers the listed mp3.
1263        assert_eq!(clip.media_urls.len(), 2);
1264        assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
1265        assert_eq!(
1266            clip.mp3_url(),
1267            "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3"
1268        );
1269        // A feed clip carries the same nested clip_roots shape as /api/clip/{id}.
1270        assert_eq!(clip.clip_attribution_type, "remix");
1271        assert_eq!(clip.clip_roots.len(), 1);
1272        assert_eq!(
1273            clip.clip_roots[0].id,
1274            "00000000-0000-4000-8000-000000000028"
1275        );
1276        assert_eq!(clip.clip_roots[0].handle, "example-artist-1");
1277    }
1278
1279    #[test]
1280    fn parse_feed_v3_page_survives_stripped_optional_fields() {
1281        // A clip with explicit/ownership/clip_roots/media_urls all stripped still
1282        // parses with sane defaults (the 490/458-of-500 optionality reality).
1283        let stripped = serde_json::json!({
1284            "clips": [{
1285                "id": "bare", "title": "Bare", "status": "complete",
1286                "metadata": {"type": "gen"}
1287            }],
1288            "has_more": false
1289        })
1290        .to_string();
1291        let (clips, has_more, next_cursor) = parse_feed_v3(stripped.as_bytes()).unwrap();
1292        assert_eq!(has_more, Some(false));
1293        assert_eq!(next_cursor, None);
1294        assert_eq!(clips.len(), 1);
1295        assert!(clips[0].media_urls.is_empty());
1296        assert_eq!(clips[0].user_id, "");
1297        assert_eq!(clips[0].batch_index, None);
1298    }
1299
1300    #[test]
1301    fn feed_v3_body_carries_filters_and_optional_cursor() {
1302        let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
1303        assert_eq!(first["filters"]["trashed"], "False");
1304        assert!(first.get("cursor").is_none());
1305        assert!(first["filters"].get("liked").is_none());
1306
1307        let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
1308        assert_eq!(liked["filters"]["liked"], "True");
1309        assert_eq!(liked["cursor"], "cur42");
1310    }
1311
1312    #[test]
1313    fn audiopipe_url_is_rewritten_to_cdn() {
1314        let raw =
1315            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
1316        assert_eq!(
1317            Clip::from_json(&raw).audio_url,
1318            "https://cdn1.suno.ai/x.mp3"
1319        );
1320    }
1321
1322    #[test]
1323    fn list_clips_authenticates_then_reads_the_feed() {
1324        let client_body = serde_json::json!({
1325            "response": {
1326                "last_active_session_id": "s",
1327                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1328            }
1329        })
1330        .to_string();
1331        let http = MockHttp::new(vec![
1332            Rule::new(
1333                "/v1/client/sessions/",
1334                200,
1335                r#"{"jwt": "a.b.c"}"#.to_string(),
1336            ),
1337            Rule::new("/v1/client", 200, client_body),
1338            Rule::new("/api/feed/v3", 200, feed_body()),
1339        ]);
1340
1341        let auth = ClerkAuth::new("eyJtoken");
1342        pollster::block_on(auth.authenticate(&http)).unwrap();
1343        let client = SunoClient::new(auth, RecordingClock::new());
1344        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1345        assert_eq!(clips.len(), 1);
1346        assert_eq!(clips[0].id, "a");
1347        assert!(complete);
1348    }
1349
1350    #[test]
1351    fn api_request_uses_clock_now_unix_for_jwt_expiry() {
1352        use crate::consts::JWT_REFRESH_BUFFER;
1353        use base64::Engine;
1354        let exp = 1_000_000i64;
1355        let payload =
1356            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
1357        let jwt_str = format!("hdr.{}.sig", payload);
1358        let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
1359        let client_body = serde_json::json!({
1360            "response": {
1361                "last_active_session_id": "s",
1362                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1363            }
1364        })
1365        .to_string();
1366
1367        let make_http = || {
1368            ScriptedHttp::new()
1369                .route("/v1/client/sessions/", Reply::json(&token_body))
1370                .route("/v1/client", Reply::json(&client_body))
1371                .route("/api/feed/v3", Reply::json(&feed_body()))
1372        };
1373
1374        // At the refresh boundary: ensure_jwt triggers a second refresh_jwt call.
1375        let http = make_http();
1376        let auth = ClerkAuth::new("eyJtoken");
1377        pollster::block_on(auth.authenticate(&http)).unwrap();
1378        let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
1379        let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1380        assert_eq!(clips.len(), 1);
1381        // authenticate + api_request refresh = 2 token calls.
1382        assert_eq!(http.count("/v1/client/sessions/"), 2);
1383
1384        // Just before the boundary: no additional refresh.
1385        let http2 = make_http();
1386        let auth2 = ClerkAuth::new("eyJtoken");
1387        pollster::block_on(auth2.authenticate(&http2)).unwrap();
1388        let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
1389        let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
1390        assert_eq!(clips2.len(), 1);
1391        // Only authenticate's token call; no extra refresh.
1392        assert_eq!(http2.count("/v1/client/sessions/"), 1);
1393    }
1394
1395    #[test]
1396    fn list_clips_reports_incomplete_when_paging_is_capped() {
1397        let mut rules = auth_rules();
1398        rules.push(Rule::new(
1399            "/api/feed/v3",
1400            200,
1401            serde_json::json!({
1402                "has_more": true,
1403                "next_cursor": "cur1",
1404                "clips": [{
1405                    "id": "a", "title": "Song A", "status": "complete",
1406                    "audio_url": "https://cdn1.suno.ai/a.mp3",
1407                    "metadata": {"type": "gen"}
1408                }]
1409            })
1410            .to_string(),
1411        ));
1412        let http = MockHttp::new(rules);
1413        let client = authed_client(&http);
1414
1415        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1416        assert!(!complete);
1417    }
1418
1419    fn auth_rules() -> Vec<Rule> {
1420        let client_body = serde_json::json!({
1421            "response": {
1422                "last_active_session_id": "s",
1423                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1424            }
1425        })
1426        .to_string();
1427        vec![
1428            Rule::new(
1429                "/v1/client/sessions/",
1430                200,
1431                r#"{"jwt": "a.b.c"}"#.to_string(),
1432            ),
1433            Rule::new("/v1/client", 200, client_body),
1434        ]
1435    }
1436
1437    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1438        let auth = ClerkAuth::new("eyJtoken");
1439        pollster::block_on(auth.authenticate(http)).unwrap();
1440        SunoClient::new(auth, RecordingClock::new())
1441    }
1442
1443    #[test]
1444    fn get_billing_info_reads_remaining_credits() {
1445        let mut rules = auth_rules();
1446        rules.push(Rule::new(
1447            BILLING_INFO_PATH,
1448            200,
1449            r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1450        ));
1451        let http = MockHttp::new(rules);
1452        let client = authed_client(&http);
1453
1454        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1455        assert_eq!(billing.total_credits_left, Some(500));
1456        assert_eq!(billing.monthly_limit, Some(1000));
1457        assert_eq!(billing.monthly_usage, Some(500));
1458    }
1459
1460    #[test]
1461    fn get_billing_info_tolerates_missing_balance() {
1462        let mut rules = auth_rules();
1463        rules.push(Rule::new(
1464            BILLING_INFO_PATH,
1465            200,
1466            r#"{"monthly_usage":12}"#.to_string(),
1467        ));
1468        let http = MockHttp::new(rules);
1469        let client = authed_client(&http);
1470
1471        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1472        assert_eq!(billing.total_credits_left, None);
1473        assert_eq!(billing.monthly_usage, Some(12));
1474    }
1475
1476    /// The anonymised full 43-field `GET /api/billing/info/` body from issue
1477    /// #223, used as a real-shape parse fixture.
1478    const BILLING_FULL: &str = r#"{
1479  "subscription_platform": "stripe",
1480  "is_active": true,
1481  "is_past_due": false,
1482  "credits": 0,
1483  "subscription_type": true,
1484  "subscription_anchor": "REDACTED",
1485  "subscription_id": "REDACTED",
1486  "renews_on": "REDACTED",
1487  "period": "month",
1488  "monthly_usage": 50,
1489  "monthly_limit": 2500,
1490  "credit_packs": [
1491    {
1492      "id": "00000000-0000-4000-8000-000000000001",
1493      "amount": 500,
1494      "price_usd": 4
1495    },
1496    {
1497      "id": "00000000-0000-4000-8000-000000000002",
1498      "amount": 1000,
1499      "price_usd": 8
1500    }
1501  ],
1502  "plan": {
1503    "id": "00000000-0000-4000-8000-000000000005",
1504    "level": 10,
1505    "plan_key": "pro",
1506    "name": "Pro Plan",
1507    "features": "Access to our newest model, v4\n2,500 credits (up to 500 songs), refreshes monthly\nCommercial use rights for songs made while subscribed\nCreate up to 10 songs at once\nEarly access to new features\nPriority creation queue\nAbility to purchase add-on credits",
1508    "monthly_price_usd": 10.0,
1509    "annual_price_usd": 96.0,
1510    "usage_plan_features": [
1511      {
1512        "name": "v4"
1513      },
1514      {
1515        "name": "cover"
1516      },
1517      {
1518        "name": "edit_mode"
1519      },
1520      {
1521        "name": "persona"
1522      },
1523      {
1524        "name": "can_buy_credit_top_ups"
1525      },
1526      {
1527        "name": "commercial_rights"
1528      },
1529      {
1530        "name": "get_stems"
1531      },
1532      {
1533        "name": "generate_song_image"
1534      },
1535      {
1536        "name": "auk"
1537      },
1538      {
1539        "name": "negative_tags"
1540      },
1541      {
1542        "name": "remaster"
1543      },
1544      {
1545        "name": "generate_song_video"
1546      },
1547      {
1548        "name": "long_uploads"
1549      },
1550      {
1551        "name": "convert_audio"
1552      },
1553      {
1554        "name": "create_control_sliders"
1555      },
1556      {
1557        "name": "playlist_condition"
1558      },
1559      {
1560        "name": "tag_upsample"
1561      },
1562      {
1563        "name": "custom_models"
1564      }
1565    ]
1566  },
1567  "models": [
1568    {
1569      "can_use": true,
1570      "max_lengths": {
1571        "title": 100,
1572        "prompt": 5000,
1573        "tags": 1000,
1574        "negative_tags": 1000,
1575        "gpt_description_prompt": 3000
1576      },
1577      "name": "Example Artist 5",
1578      "external_key": "chirp-fenix",
1579      "major_version": 5,
1580      "description": "[description redacted]",
1581      "is_default_free_model": false,
1582      "is_default_model": true,
1583      "badges": [
1584        "pro"
1585      ],
1586      "model_badges": [
1587        {
1588          "display_name": "Example Artist 1",
1589          "light": {
1590            "text_color": "000000",
1591            "background_color": "00000000",
1592            "border_color": "000000"
1593          },
1594          "dark": {
1595            "text_color": "FFFFFF",
1596            "background_color": "00000000",
1597            "border_color": "FFFFFF"
1598          }
1599        }
1600      ],
1601      "style": {
1602        "light": {
1603          "text_color": "FD429C"
1604        },
1605        "dark": {
1606          "text_color": "FD429C"
1607        }
1608      },
1609      "capabilities": [
1610        "all"
1611      ],
1612      "features": [
1613        "create_control_sliders",
1614        "tag_upsample",
1615        "mumble_mode",
1616        "vox_and_voices",
1617        "reuse_styles_lyrics"
1618      ],
1619      "allowed_condition_combinations": [
1620        [
1621          "extend"
1622        ],
1623        [
1624          "cover"
1625        ],
1626        [
1627          "infill"
1628        ],
1629        [
1630          "persona"
1631        ],
1632        [
1633          "persona",
1634          "extend"
1635        ],
1636        [
1637          "persona",
1638          "cover"
1639        ],
1640        [
1641          "playlist"
1642        ],
1643        [
1644          "underpaint"
1645        ],
1646        [
1647          "overpaint"
1648        ],
1649        [
1650          "vox"
1651        ],
1652        [
1653          "vox",
1654          "extend"
1655        ],
1656        [
1657          "vox",
1658          "cover"
1659        ],
1660        [
1661          "vox",
1662          "playlist"
1663        ],
1664        [
1665          "persona",
1666          "infill"
1667        ],
1668        [
1669          "cover",
1670          "infill"
1671        ]
1672      ],
1673      "id": "00000000-0000-4000-8000-000000000006"
1674    }
1675  ],
1676  "plan_price": 10.0,
1677  "plan_currency": "AUD",
1678  "plan_currency_price": 15.0,
1679  "payment_method_type": "card",
1680  "can_upgrade_immediately": true,
1681  "plans": [
1682    {
1683      "id": "00000000-0000-4000-8000-000000000015",
1684      "level": 0,
1685      "plan_key": "free",
1686      "name": "Free Plan",
1687      "features": "50 credits renew daily (10 songs)\nCreate up to 4 songs at once\nNo commercial use\nNo credit top ups\nShared generation queue",
1688      "monthly_price_usd": 0.0,
1689      "annual_price_usd": 0.0,
1690      "usage_plan_features": [
1691        {
1692          "name": "tag_upsample"
1693        }
1694      ],
1695      "prices": []
1696    }
1697  ],
1698  "accessible_features": [
1699    {
1700      "name": "v4"
1701    },
1702    {
1703      "name": "cover"
1704    },
1705    {
1706      "name": "edit_mode"
1707    },
1708    {
1709      "name": "persona"
1710    },
1711    {
1712      "name": "can_buy_credit_top_ups"
1713    },
1714    {
1715      "name": "commercial_rights"
1716    },
1717    {
1718      "name": "get_stems"
1719    },
1720    {
1721      "name": "generate_song_image"
1722    },
1723    {
1724      "name": "auk"
1725    },
1726    {
1727      "name": "negative_tags"
1728    },
1729    {
1730      "name": "remaster"
1731    },
1732    {
1733      "name": "generate_song_video"
1734    },
1735    {
1736      "name": "long_uploads"
1737    },
1738    {
1739      "name": "convert_audio"
1740    },
1741    {
1742      "name": "create_control_sliders"
1743    },
1744    {
1745      "name": "playlist_condition"
1746    },
1747    {
1748      "name": "tag_upsample"
1749    },
1750    {
1751      "name": "custom_models"
1752    }
1753  ],
1754  "revcat_subscriptions_offering_id": "REDACTED",
1755  "total_credits_left": 2450,
1756  "free_persona_clips_remaining": 0,
1757  "free_cover_clips_remaining": 0,
1758  "free_remasters_remaining": 0,
1759  "free_mobile_remasters_remaining": 0,
1760  "free_mobile_v4_gens_remaining": 0,
1761  "free_web_v4_gens_remaining": 0,
1762  "free_vox_gens_remaining": 0,
1763  "has_been_subscriber_before": true,
1764  "has_valid_school_email": false,
1765  "has_been_student_subscriber_before": false,
1766  "day0_boost": -1,
1767  "promotions": [],
1768  "audio_upload_limits": {
1769    "min": 6,
1770    "max": 1800
1771  },
1772  "voice_upload_limits": {
1773    "min": 10,
1774    "max": 900
1775  },
1776  "voice_record_limits": {
1777    "min": 10,
1778    "max": 240
1779  },
1780  "period_end": "REDACTED",
1781  "remaster_model_types": [
1782    {
1783      "name": "Example Artist 5",
1784      "external_key": "chirp-flounder",
1785      "is_default_model": true,
1786      "can_use": false
1787    },
1788    {
1789      "name": "Example Artist 2",
1790      "external_key": "chirp-carp",
1791      "is_default_model": false,
1792      "can_use": false
1793    },
1794    {
1795      "name": "v4.5+",
1796      "external_key": "chirp-bass",
1797      "is_default_model": false,
1798      "can_use": false
1799    }
1800  ],
1801  "is_pause_scheduled": false,
1802  "is_paused": false,
1803  "is_gifted": false
1804}"#;
1805
1806    #[test]
1807    fn parse_billing_info_reads_full_real_body() {
1808        let billing = parse_billing_info(BILLING_FULL.as_bytes()).unwrap();
1809        assert_eq!(billing.total_credits_left, Some(2450));
1810        assert_eq!(billing.monthly_limit, Some(2500));
1811        assert_eq!(billing.monthly_usage, Some(50));
1812        assert_eq!(billing.credits, Some(0));
1813        assert_eq!(billing.period.as_deref(), Some("month"));
1814        assert_eq!(billing.is_active, Some(true));
1815        assert_eq!(billing.is_paused, Some(false));
1816        assert_eq!(billing.is_past_due, Some(false));
1817        assert_eq!(billing.is_gifted, Some(false));
1818        assert_eq!(billing.subscription_platform.as_deref(), Some("stripe"));
1819        assert_eq!(billing.plan_key.as_deref(), Some("pro"));
1820        assert_eq!(billing.plan_name.as_deref(), Some("Pro Plan"));
1821        assert_eq!(billing.plan_level, Some(10));
1822        assert!(billing.can_get_stems());
1823        assert!(billing.can_convert_audio());
1824        assert!(billing.has_feature("custom_models"));
1825    }
1826
1827    #[test]
1828    fn json_i64_reads_string_encoded_integer() {
1829        let billing = parse_billing_info(br#"{"total_credits_left":"2450"}"#).unwrap();
1830        assert_eq!(billing.total_credits_left, Some(2450));
1831    }
1832
1833    #[test]
1834    fn json_i64_reads_integral_float() {
1835        let billing = parse_billing_info(br#"{"total_credits_left":2450.0}"#).unwrap();
1836        assert_eq!(billing.total_credits_left, Some(2450));
1837    }
1838
1839    #[test]
1840    fn json_i64_reads_negative_sentinel() {
1841        let billing = parse_billing_info(br#"{"total_credits_left":-1}"#).unwrap();
1842        assert_eq!(billing.total_credits_left, Some(-1));
1843    }
1844
1845    #[test]
1846    fn json_i64_rejects_non_integral_float_but_object_still_parses() {
1847        let billing =
1848            parse_billing_info(br#"{"total_credits_left":2450.5,"period":"month"}"#).unwrap();
1849        assert_eq!(billing.total_credits_left, None);
1850        assert_eq!(billing.period.as_deref(), Some("month"));
1851    }
1852
1853    #[test]
1854    fn str_to_i64_handles_encodings_and_junk() {
1855        assert_eq!(str_to_i64("2450"), Some(2450));
1856        assert_eq!(str_to_i64("2450.0"), Some(2450));
1857        assert_eq!(str_to_i64("-1"), Some(-1));
1858        assert_eq!(str_to_i64("2450.5"), None);
1859        assert_eq!(str_to_i64(".5"), None);
1860        assert_eq!(str_to_i64("nope"), None);
1861        assert_eq!(str_to_i64("99999999999999999999999"), None);
1862    }
1863
1864    #[test]
1865    fn json_i64_rejects_overflow() {
1866        let billing =
1867            parse_billing_info(br#"{"total_credits_left":99999999999999999999999}"#).unwrap();
1868        assert_eq!(billing.total_credits_left, None);
1869    }
1870
1871    #[test]
1872    fn json_i64_covers_i64_and_float_boundaries() {
1873        // Integers arrive through the lossless i64 path, so the full i64 range works.
1874        assert_eq!(json_i64(&serde_json::json!(i64::MAX)), Some(i64::MAX));
1875        assert_eq!(json_i64(&serde_json::json!(i64::MIN)), Some(i64::MIN));
1876        // A JSON integer of 2^63 exceeds i64::MAX and must not saturate.
1877        assert_eq!(
1878            json_i64(&serde_json::json!(9_223_372_036_854_775_808_u64)),
1879            None
1880        );
1881        // Floats are trusted only below 2^53, so both i64 extremes are rejected.
1882        assert_eq!(f64_to_i64(i64::MAX as f64), None);
1883        assert_eq!(f64_to_i64(i64::MIN as f64), None);
1884        assert_eq!(f64_to_i64(2450.5), None);
1885        assert_eq!(f64_to_i64(f64::NAN), None);
1886        assert_eq!(f64_to_i64(f64::INFINITY), None);
1887    }
1888
1889    #[test]
1890    fn f64_to_i64_rejects_values_below_i64_min() {
1891        // A float below i64::MIN must not silently saturate to i64::MIN.
1892        let below_min: f64 = "-9223372036854775809".parse().unwrap();
1893        assert_eq!(f64_to_i64(below_min), None);
1894        // The matching string is rejected by the lossless i64 parse.
1895        assert_eq!(str_to_i64("-9223372036854775809"), None);
1896        assert_eq!(json_i64(&serde_json::json!("-9223372036854775809")), None);
1897    }
1898
1899    #[test]
1900    fn f64_to_i64_trusts_only_the_safe_integer_range() {
1901        // 2^53 - 1 is the largest integer an f64 represents exactly.
1902        assert_eq!(
1903            f64_to_i64(9_007_199_254_740_991.0),
1904            Some(9_007_199_254_740_991)
1905        );
1906        // 9007199254740993 (2^53 + 1) is not representable, so serde rounds it to
1907        // 2^53 before we see it; the rounded value must be refused, not returned.
1908        let rounded: f64 = "9007199254740993".parse().unwrap();
1909        assert_eq!(rounded, 9_007_199_254_740_992.0);
1910        assert_eq!(f64_to_i64(rounded), None);
1911    }
1912
1913    #[test]
1914    fn parse_billing_info_defaults_missing_fields() {
1915        let billing = parse_billing_info(br#"{"monthly_usage":12}"#).unwrap();
1916        assert_eq!(billing.total_credits_left, None);
1917        assert_eq!(billing.monthly_usage, Some(12));
1918        assert_eq!(billing.plan_key, None);
1919        assert!(billing.features.is_empty());
1920        assert!(!billing.can_get_stems());
1921    }
1922
1923    #[test]
1924    fn from_billing_json_ignores_surprising_types() {
1925        // `subscription_type` is a bool despite its name; a numeric field carrying
1926        // the wrong type must fall back to None rather than panic.
1927        let value = serde_json::json!({
1928            "subscription_type": true,
1929            "total_credits_left": {"unexpected": "object"},
1930            "is_active": "yes",
1931        });
1932        let billing = from_billing_json(&value);
1933        assert_eq!(billing.total_credits_left, None);
1934        assert_eq!(billing.is_active, None);
1935    }
1936
1937    #[test]
1938    fn parse_billing_info_treats_non_object_json_as_default() {
1939        for body in [
1940            b"null".as_slice(),
1941            b"[]".as_slice(),
1942            br#""hello""#.as_slice(),
1943        ] {
1944            assert_eq!(parse_billing_info(body).unwrap(), BillingInfo::default());
1945        }
1946    }
1947
1948    #[test]
1949    fn parse_billing_info_rejects_non_json_bytes() {
1950        let err = parse_billing_info(b"nope").unwrap_err();
1951        assert!(err.to_string().contains("invalid billing JSON"));
1952    }
1953
1954    #[test]
1955    fn from_billing_json_unions_feature_sources() {
1956        let accessible_only = serde_json::json!({
1957            "accessible_features": [{"name": "get_stems"}],
1958        });
1959        assert!(from_billing_json(&accessible_only).can_get_stems());
1960
1961        let plan_only = serde_json::json!({
1962            "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
1963        });
1964        assert!(from_billing_json(&plan_only).can_convert_audio());
1965
1966        let both = serde_json::json!({
1967            "accessible_features": [{"name": "get_stems"}, {"name": ""}, {"other": "x"}],
1968            "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
1969        });
1970        let billing = from_billing_json(&both);
1971        assert!(billing.can_get_stems());
1972        assert!(billing.can_convert_audio());
1973        // Empty and malformed feature entries are ignored.
1974        assert_eq!(billing.features.len(), 2);
1975    }
1976
1977    #[test]
1978    fn aligned_lyrics_reads_words_and_lines() {
1979        let mut rules = auth_rules();
1980        let body = serde_json::json!({
1981            "aligned_words": [
1982                {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1983            ],
1984            "aligned_lyrics": [
1985                {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1986                 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1987            ],
1988            "hoot_cer": 0.2, "is_streamed": false
1989        })
1990        .to_string();
1991        rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1992        let http = MockHttp::new(rules);
1993        let client = authed_client(&http);
1994
1995        let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1996        assert_eq!(aligned.words.len(), 1);
1997        assert_eq!(aligned.lines.len(), 1);
1998        assert_eq!(aligned.lines[0].section, "Verse 1");
1999        assert!(!aligned.is_empty());
2000    }
2001
2002    #[test]
2003    fn aligned_lyrics_empty_arrays_map_to_empty() {
2004        let mut rules = auth_rules();
2005        rules.push(Rule::new(
2006            "/aligned_lyrics/v2/",
2007            200,
2008            r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
2009        ));
2010        let http = MockHttp::new(rules);
2011        let client = authed_client(&http);
2012
2013        let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
2014        assert!(aligned.is_empty());
2015    }
2016
2017    #[test]
2018    fn aligned_lyrics_maps_404_to_empty() {
2019        let mut rules = auth_rules();
2020        rules.push(Rule::new(
2021            "/aligned_lyrics/v2/",
2022            404,
2023            "not found".to_string(),
2024        ));
2025        let http = MockHttp::new(rules);
2026        let client = authed_client(&http);
2027
2028        let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
2029        assert!(aligned.is_empty());
2030    }
2031
2032    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
2033        let auth = ClerkAuth::new("eyJtoken");
2034        pollster::block_on(auth.authenticate(http)).unwrap();
2035        SunoClient::new(auth, clock)
2036    }
2037
2038    fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
2039        let mut page = serde_json::json!({
2040            "has_more": next_cursor.is_some(),
2041            "clips": [{
2042                "id": id, "title": "Song", "status": "complete",
2043                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
2044                "metadata": {"type": "gen"}
2045            }]
2046        });
2047        if let Some(cursor) = next_cursor {
2048            page["next_cursor"] = serde_json::json!(cursor);
2049        }
2050        page.to_string()
2051    }
2052
2053    #[test]
2054    fn list_clips_retries_a_rate_limited_page() {
2055        let http = ScriptedHttp::new().with_auth().route_seq(
2056            "/api/feed/v3",
2057            vec![Reply::status(429), Reply::json(&feed_body())],
2058        );
2059        let clock = RecordingClock::new();
2060        let client = scripted_client(&http, clock.clone());
2061
2062        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2063        assert_eq!(clips.len(), 1);
2064        assert!(complete);
2065        // The throttled page was retried once, waiting the default post-429 wait.
2066        assert_eq!(http.count("/api/feed/v3"), 2);
2067        assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2068    }
2069
2070    #[test]
2071    fn list_clips_honours_retry_after_on_a_throttled_page() {
2072        let http = ScriptedHttp::new().with_auth().route_seq(
2073            "/api/feed/v3",
2074            vec![
2075                Reply::status(429).with_retry_after(7),
2076                Reply::json(&feed_body()),
2077            ],
2078        );
2079        let clock = RecordingClock::new();
2080        let client = scripted_client(&http, clock.clone());
2081
2082        let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2083        assert_eq!(clips.len(), 1);
2084        // The server's Retry-After is honoured directly as the post-429 wait.
2085        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2086    }
2087
2088    #[test]
2089    fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
2090        // A 429 mid-walk must re-POST the *same* cursor, not skip a page.
2091        let http = ScriptedHttp::new().with_auth().route_seq(
2092            "/api/feed/v3",
2093            vec![
2094                Reply::json(&one_clip_page("a", Some("cur1"))),
2095                Reply::status(429),
2096                Reply::json(&one_clip_page("b", None)),
2097            ],
2098        );
2099        let clock = RecordingClock::new();
2100        let client = scripted_client(&http, clock.clone());
2101
2102        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2103        assert!(complete);
2104        assert_eq!(clips.len(), 2);
2105        let bodies = http.bodies();
2106        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2107        assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
2108        // The retry (body 2) carries the SAME cursor as the throttled call (body 2 == the
2109        // second feed POST), i.e. the cursor from page 1's next_cursor.
2110        let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2111        let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
2112        assert_eq!(retried["cursor"], "cur1");
2113        assert_eq!(after_retry["cursor"], "cur1");
2114    }
2115
2116    #[test]
2117    fn list_clips_threads_the_cursor_across_pages() {
2118        let http = ScriptedHttp::new().with_auth().route_seq(
2119            "/api/feed/v3",
2120            vec![
2121                Reply::json(&one_clip_page("a", Some("cur1"))),
2122                Reply::json(&one_clip_page("b", None)),
2123            ],
2124        );
2125        let clock = RecordingClock::new();
2126        let client = scripted_client(&http, clock.clone());
2127
2128        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2129        assert!(complete);
2130        assert_eq!(clips.len(), 2);
2131        let bodies = http.bodies();
2132        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2133        assert_eq!(feed_bodies.len(), 2);
2134        let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
2135        let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2136        // Page 1 omits the cursor; page 2 carries exactly page 1's next_cursor.
2137        assert!(page1.get("cursor").is_none());
2138        assert_eq!(page2["cursor"], "cur1");
2139    }
2140
2141    #[test]
2142    fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
2143        // has_more == true with no usable next_cursor: a truncated feed. The walk
2144        // must stop, report incomplete, and never re-POST a null cursor.
2145        let page = serde_json::json!({
2146            "has_more": true,
2147            "clips": [{
2148                "id": "a", "title": "Song", "status": "complete",
2149                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2150            }]
2151        })
2152        .to_string();
2153        let http = ScriptedHttp::new()
2154            .with_auth()
2155            .route("/api/feed/v3", Reply::json(&page));
2156        let clock = RecordingClock::new();
2157        let client = scripted_client(&http, clock.clone());
2158
2159        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2160        assert!(!complete);
2161        assert_eq!(clips.len(), 1);
2162        assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
2163    }
2164
2165    #[test]
2166    fn list_clips_is_incomplete_when_has_more_is_missing() {
2167        // A page with no has_more key must not be read as a fully drained feed.
2168        let page = serde_json::json!({
2169            "clips": [{
2170                "id": "a", "title": "Song", "status": "complete",
2171                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2172            }]
2173        })
2174        .to_string();
2175        let http = ScriptedHttp::new()
2176            .with_auth()
2177            .route("/api/feed/v3", Reply::json(&page));
2178        let clock = RecordingClock::new();
2179        let client = scripted_client(&http, clock.clone());
2180
2181        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2182        assert!(!complete);
2183        assert_eq!(clips.len(), 1);
2184        assert_eq!(http.count("/api/feed/v3"), 1);
2185    }
2186
2187    #[test]
2188    fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
2189        let http = ScriptedHttp::new().with_auth().route_seq(
2190            "/api/feed/v3",
2191            vec![
2192                Reply::json(&one_clip_page("a", Some("cur1"))),
2193                Reply::status(500),
2194            ],
2195        );
2196        let clock = RecordingClock::new();
2197        let client = scripted_client(&http, clock.clone());
2198
2199        let result = pollster::block_on(client.list_clips(&http, false, None));
2200        assert!(matches!(result, Err(Error::Api(_))));
2201    }
2202
2203    #[test]
2204    fn list_clips_is_complete_on_an_empty_drained_feed() {
2205        // An empty but fully drained feed is authoritative (complete = true);
2206        // deletion is separately gated by there being a mirror source.
2207        let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
2208        let http = ScriptedHttp::new()
2209            .with_auth()
2210            .route("/api/feed/v3", Reply::json(&page));
2211        let clock = RecordingClock::new();
2212        let client = scripted_client(&http, clock.clone());
2213
2214        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2215        assert!(complete);
2216        assert!(clips.is_empty());
2217    }
2218
2219    #[test]
2220    fn list_clips_liked_scope_sends_the_liked_filter() {
2221        let http = ScriptedHttp::new()
2222            .with_auth()
2223            .route("/api/feed/v3", Reply::json(&feed_body()));
2224        let clock = RecordingClock::new();
2225        let client = scripted_client(&http, clock.clone());
2226
2227        let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
2228        let bodies = http.bodies();
2229        let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
2230        let value: Value = serde_json::from_str(feed_body).unwrap();
2231        assert_eq!(value["filters"]["liked"], "True");
2232        assert_eq!(value["filters"]["trashed"], "False");
2233    }
2234
2235    #[test]
2236    fn list_clips_does_not_pace_an_unthrottled_walk() {
2237        let http = ScriptedHttp::new().with_auth().route_seq(
2238            "/api/feed/v3",
2239            vec![
2240                Reply::json(&one_clip_page("a", Some("cur1"))),
2241                Reply::json(&one_clip_page("e", None)),
2242            ],
2243        );
2244        let clock = RecordingClock::new();
2245        let client = scripted_client(&http, clock.clone());
2246
2247        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2248        assert!(complete);
2249        assert_eq!(clips.len(), 2);
2250        assert_eq!(http.count("/api/feed/v3"), 2);
2251        // Pacing is reactive: with no 429 the whole walk waits nowhere.
2252        assert!(clock.sleeps().is_empty());
2253    }
2254
2255    #[test]
2256    fn list_clips_slows_its_pace_after_a_throttled_page() {
2257        let http = ScriptedHttp::new().with_auth().route_seq(
2258            "/api/feed/v3",
2259            vec![
2260                Reply::status(429),
2261                Reply::json(&one_clip_page("a", Some("cur1"))),
2262                Reply::json(&one_clip_page("e", None)),
2263            ],
2264        );
2265        let clock = RecordingClock::new();
2266        let client = scripted_client(&http, clock.clone());
2267
2268        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2269        assert!(complete);
2270        assert_eq!(clips.len(), 2);
2271        // The 429 halved the rate, so the default post-429 wait is followed by a
2272        // doubled inter-page pace (500ms to 1s) for the next page.
2273        assert_eq!(
2274            clock.sleeps(),
2275            vec![Duration::from_secs(5), Duration::from_secs(1)]
2276        );
2277    }
2278
2279    #[test]
2280    fn list_clips_gives_up_after_max_retries() {
2281        let http = ScriptedHttp::new()
2282            .with_auth()
2283            .route("/api/feed/v3", Reply::status(429));
2284        let clock = RecordingClock::new();
2285        let client = scripted_client(&http, clock.clone());
2286
2287        let result = pollster::block_on(client.list_clips(&http, false, None));
2288        assert!(matches!(result, Err(Error::RateLimited { .. })));
2289        let budget = crate::consts::API_MAX_RETRIES as usize;
2290        assert_eq!(clock.sleeps().len(), budget);
2291        assert_eq!(http.count("/api/feed/v3"), budget + 1);
2292    }
2293
2294    #[test]
2295    fn parse_clip_accepts_bare_and_wrapped_shapes() {
2296        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
2297        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
2298
2299        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
2300        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
2301
2302        let missing = serde_json::json!({"detail": "not found"}).to_string();
2303        assert!(parse_clip(missing.as_bytes()).is_none());
2304    }
2305
2306    #[test]
2307    fn get_clip_uses_the_dedicated_endpoint() {
2308        let clip_body = serde_json::json!({
2309            "id": "z", "title": "Zed", "status": "complete",
2310            "audio_url": "https://cdn1.suno.ai/z.mp3",
2311            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
2312        })
2313        .to_string();
2314        let mut rules = auth_rules();
2315        rules.push(Rule::new("/api/clip/", 200, clip_body));
2316        let http = MockHttp::new(rules);
2317        let client = authed_client(&http);
2318
2319        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
2320        assert_eq!(clip.id, "z");
2321        assert_eq!(clip.title, "Zed");
2322        assert_eq!(clip.tags, "jazz");
2323    }
2324
2325    #[test]
2326    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
2327        let mut rules = auth_rules();
2328        rules.push(Rule::new(
2329            "/api/clip/",
2330            404,
2331            r#"{"detail": "not found"}"#.to_string(),
2332        ));
2333        rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
2334        let http = MockHttp::new(rules);
2335        let client = authed_client(&http);
2336
2337        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
2338        assert_eq!(clip.id, "a");
2339        assert_eq!(clip.tags, "rock");
2340    }
2341
2342    #[test]
2343    fn request_wav_accepts_a_2xx_status() {
2344        let mut rules = auth_rules();
2345        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
2346        let http = MockHttp::new(rules);
2347        let client = authed_client(&http);
2348
2349        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
2350    }
2351
2352    #[test]
2353    fn wav_url_reads_the_ready_url() {
2354        let mut rules = auth_rules();
2355        rules.push(Rule::new(
2356            "/wav_file/",
2357            200,
2358            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
2359        ));
2360        let http = MockHttp::new(rules);
2361        let client = authed_client(&http);
2362
2363        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2364        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
2365    }
2366
2367    #[test]
2368    fn wav_url_is_none_until_the_render_is_ready() {
2369        let mut rules = auth_rules();
2370        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
2371        let http = MockHttp::new(rules);
2372        let client = authed_client(&http);
2373
2374        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2375        assert_eq!(url, None);
2376    }
2377
2378    #[test]
2379    fn wav_url_404_maps_to_none() {
2380        // A 404 means the render is absent or was never requested, not a run
2381        // failure: map it to None, symmetric with aligned_lyrics, so the fetch
2382        // flow polls again rather than aborting the whole render.
2383        let mut rules = auth_rules();
2384        rules.push(Rule::new(
2385            "/wav_file/",
2386            404,
2387            r#"{"detail": "Not found."}"#.to_string(),
2388        ));
2389        let http = MockHttp::new(rules);
2390        let client = authed_client(&http);
2391
2392        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2393        assert_eq!(url, None);
2394    }
2395
2396    #[test]
2397    fn get_clips_by_ids_keeps_infill_and_upload_ancestors() {
2398        // The gap-fill path must not apply the listing's downloadability filter:
2399        // an infill ancestor and an upload root both survive, returned by the
2400        // batch `get_songs_by_ids` call.
2401        let p1 = serde_json::json!({
2402            "id": "p1", "title": "Infill Ancestor", "status": "complete",
2403            "metadata": {"type": "gen", "task": "infill"}
2404        })
2405        .to_string();
2406        let p2 = serde_json::json!({
2407            "id": "p2", "title": "Uploaded Root", "status": "complete",
2408            "metadata": {"type": "upload"}
2409        })
2410        .to_string();
2411        let batch = format!(r#"{{"clips":[{p1},{p2}]}}"#);
2412        let mut rules = auth_rules();
2413        rules.push(Rule::new("get_songs_by_ids", 200, batch));
2414        rules.push(Rule::new("/api/clip/p1", 200, p1));
2415        rules.push(Rule::new("/api/clip/p2", 200, p2));
2416        let http = MockHttp::new(rules);
2417        let client = authed_client(&http);
2418
2419        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
2420        assert_eq!(
2421            clips.len(),
2422            2,
2423            "infill and upload ancestors must not be filtered"
2424        );
2425        assert_eq!(clips[0].id, "p1");
2426        assert_eq!(clips[1].id, "p2");
2427    }
2428
2429    #[test]
2430    fn get_clips_by_ids_returns_a_trashed_clip() {
2431        // A trashed ancestor must still be retrievable by id (the v2 `?ids=`
2432        // capability that `get_songs_by_ids` now restores in one request).
2433        let trashed = serde_json::json!({
2434            "id": "t1", "title": "Trashed Ancestor", "status": "complete",
2435            "is_trashed": true, "metadata": {"type": "gen"}
2436        })
2437        .to_string();
2438        let batch = format!(r#"{{"clips":[{trashed}]}}"#);
2439        let mut rules = auth_rules();
2440        rules.push(Rule::new("get_songs_by_ids", 200, batch));
2441        rules.push(Rule::new("/api/clip/t1", 200, trashed));
2442        let http = MockHttp::new(rules);
2443        let client = authed_client(&http);
2444
2445        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
2446        assert_eq!(clips.len(), 1);
2447        assert_eq!(clips[0].id, "t1");
2448        assert!(clips[0].is_trashed);
2449    }
2450
2451    #[test]
2452    fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
2453        let only = serde_json::json!({
2454            "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
2455        })
2456        .to_string();
2457        // The batch returns "only" and omits "gone"; "gone" then falls back to a
2458        // per-id fetch that 404s and is skipped.
2459        let batch = format!(r#"{{"clips":[{only}]}}"#);
2460        let http = ScriptedHttp::new()
2461            .with_auth()
2462            .route("get_songs_by_ids", Reply::json(&batch))
2463            .route("/api/clip/gone", Reply::status(404));
2464        let client = scripted_client(&http, RecordingClock::new());
2465
2466        let clips =
2467            pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
2468                .unwrap();
2469        assert_eq!(clips.len(), 1, "the 404 id is skipped");
2470        assert_eq!(clips[0].id, "only");
2471        // "only" is deduped and returned by the batch, so it is never per-id
2472        // fetched; "gone" is attempted once via the per-id fallback.
2473        assert_eq!(
2474            http.count("get_songs_by_ids"),
2475            1,
2476            "one batch call for both ids"
2477        );
2478        assert_eq!(http.count("/api/clip/only"), 0);
2479        assert_eq!(http.count("/api/clip/gone"), 1);
2480    }
2481
2482    #[test]
2483    fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
2484        // With no batch route the batch is unavailable, so both calls fall back
2485        // to per-id and must return the deduped input order regardless of the
2486        // concurrency used.
2487        let a = serde_json::json!({
2488            "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
2489        })
2490        .to_string();
2491        let b = serde_json::json!({
2492            "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
2493        })
2494        .to_string();
2495        let c = serde_json::json!({
2496            "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
2497        })
2498        .to_string();
2499        let http = ScriptedHttp::new()
2500            .with_auth()
2501            .route("/api/clip/a", Reply::json(&a))
2502            .route("/api/clip/b", Reply::json(&b))
2503            .route("/api/clip/c", Reply::json(&c));
2504        let client = scripted_client(&http, RecordingClock::new());
2505        let ids = ["b", "a", "c", "a"];
2506
2507        let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
2508        let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
2509
2510        let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
2511        let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
2512        assert_eq!(serial_ids, vec!["b", "a", "c"]);
2513        assert_eq!(concurrent_ids, serial_ids);
2514    }
2515
2516    /// A minimal complete-clip body for the batch tests below.
2517    fn clip_body(id: &str) -> String {
2518        format!(r#"{{"id":"{id}","title":"T","status":"complete","metadata":{{"type":"gen"}}}}"#)
2519    }
2520
2521    #[test]
2522    fn get_songs_by_ids_maps_the_batch_body_matched_by_id_in_input_order() {
2523        // The batch returns the clips out of order; the result must follow the
2524        // de-duplicated input order, matched by id, never the response position.
2525        let batch = format!(
2526            r#"{{"clips":[{},{},{}]}}"#,
2527            clip_body("c"),
2528            clip_body("a"),
2529            clip_body("b")
2530        );
2531        let http = ScriptedHttp::new()
2532            .with_auth()
2533            .route("get_songs_by_ids", Reply::json(&batch));
2534        let client = scripted_client(&http, RecordingClock::new());
2535
2536        let clips =
2537            pollster::block_on(client.get_songs_by_ids(&http, &["a", "b", "c", "a"])).unwrap();
2538        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2539        assert_eq!(ids, vec!["a", "b", "c"], "input order, not response order");
2540        assert_eq!(http.count("get_songs_by_ids"), 1, "one chunk, one request");
2541    }
2542
2543    #[test]
2544    fn get_songs_by_ids_drops_clips_that_were_not_requested() {
2545        // A defensive body carrying an extra id must not leak into the result.
2546        let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("x"));
2547        let http = ScriptedHttp::new()
2548            .with_auth()
2549            .route("get_songs_by_ids", Reply::json(&batch));
2550        let client = scripted_client(&http, RecordingClock::new());
2551
2552        let clips = pollster::block_on(client.get_songs_by_ids(&http, &["a"])).unwrap();
2553        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2554        assert_eq!(ids, vec!["a"], "an unrequested id is dropped");
2555    }
2556
2557    #[test]
2558    fn get_songs_by_ids_chunks_ids_beyond_the_chunk_size() {
2559        // 21 ids span two chunks (20 + 1), one batch request each, with the
2560        // input order preserved across the chunk boundary.
2561        let ids: Vec<String> = (0..21).map(|i| format!("id-{i:02}")).collect();
2562        let body = |slice: &[String]| {
2563            let clips: Vec<String> = slice.iter().map(|id| clip_body(id)).collect();
2564            format!(r#"{{"clips":[{}]}}"#, clips.join(","))
2565        };
2566        let http = ScriptedHttp::new().with_auth().route_seq(
2567            "get_songs_by_ids",
2568            vec![
2569                Reply::json(&body(&ids[..20])),
2570                Reply::json(&body(&ids[20..])),
2571            ],
2572        );
2573        let client = scripted_client(&http, RecordingClock::new());
2574        let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2575
2576        let clips = pollster::block_on(client.get_songs_by_ids(&http, &refs)).unwrap();
2577        let got: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2578        assert_eq!(got, refs, "all 21 ids returned in input order");
2579        assert_eq!(
2580            http.count("get_songs_by_ids"),
2581            2,
2582            "two chunks -> two requests"
2583        );
2584        let batch_calls: Vec<String> = http
2585            .calls()
2586            .into_iter()
2587            .filter(|url| url.contains("get_songs_by_ids"))
2588            .collect();
2589        assert_eq!(
2590            batch_calls[0].matches("ids=").count(),
2591            20,
2592            "first chunk of 20"
2593        );
2594        assert_eq!(
2595            batch_calls[1].matches("ids=").count(),
2596            1,
2597            "second chunk of 1"
2598        );
2599    }
2600
2601    #[test]
2602    fn get_clips_by_ids_batch_first_does_not_fetch_per_id_when_batch_is_complete() {
2603        // When the batch returns every requested id, no per-id request is made.
2604        let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("b"));
2605        let http = ScriptedHttp::new()
2606            .with_auth()
2607            .route("get_songs_by_ids", Reply::json(&batch))
2608            .route("/api/clip/a", Reply::json(&clip_body("a")))
2609            .route("/api/clip/b", Reply::json(&clip_body("b")));
2610        let client = scripted_client(&http, RecordingClock::new());
2611
2612        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2613        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2614        assert_eq!(ids, vec!["a", "b"]);
2615        assert_eq!(http.count("get_songs_by_ids"), 1);
2616        assert_eq!(
2617            http.count("/api/clip/"),
2618            0,
2619            "a complete batch needs no per-id fallback"
2620        );
2621    }
2622
2623    #[test]
2624    fn get_clips_by_ids_fills_ids_the_batch_omits_via_per_id() {
2625        // The batch returns only "a"; "b" is filled by a per-id fetch.
2626        let batch = format!(r#"{{"clips":[{}]}}"#, clip_body("a"));
2627        let http = ScriptedHttp::new()
2628            .with_auth()
2629            .route("get_songs_by_ids", Reply::json(&batch))
2630            .route("/api/clip/b", Reply::json(&clip_body("b")));
2631        let client = scripted_client(&http, RecordingClock::new());
2632
2633        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2634        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2635        assert_eq!(ids, vec!["a", "b"], "omitted id is filled, order preserved");
2636        assert_eq!(http.count("/api/clip/a"), 0, "a came from the batch");
2637        assert_eq!(http.count("/api/clip/b"), 1, "b was filled per-id");
2638    }
2639
2640    #[test]
2641    fn get_clips_by_ids_falls_back_to_per_id_on_a_malformed_batch_body() {
2642        // A 200 body that is not `{"clips":[…]}` yields nothing for the chunk, so
2643        // every requested id is recovered by the per-id fallback.
2644        let http = ScriptedHttp::new()
2645            .with_auth()
2646            .route("get_songs_by_ids", Reply::json("not-json{"))
2647            .route("/api/clip/a", Reply::json(&clip_body("a")))
2648            .route("/api/clip/b", Reply::json(&clip_body("b")));
2649        let client = scripted_client(&http, RecordingClock::new());
2650
2651        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2652        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2653        assert_eq!(ids, vec!["a", "b"]);
2654        assert_eq!(http.count("/api/clip/a"), 1);
2655        assert_eq!(http.count("/api/clip/b"), 1);
2656    }
2657
2658    #[test]
2659    fn get_clips_by_ids_propagates_a_batch_rate_limit_without_per_id_fan_out() {
2660        // A 429 that survives the retry budget propagates: it must never fan out
2661        // into a burst of per-id requests that would only deepen the throttling.
2662        let http = ScriptedHttp::new()
2663            .with_auth()
2664            .route("get_songs_by_ids", Reply::status(429))
2665            .route("/api/clip/a", Reply::json(&clip_body("a")))
2666            .route("/api/clip/b", Reply::json(&clip_body("b")));
2667        let client = scripted_client(&http, RecordingClock::new());
2668
2669        let result = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4));
2670        assert!(
2671            matches!(result, Err(Error::RateLimited { .. })),
2672            "an exhausted 429 propagates"
2673        );
2674        assert_eq!(
2675            http.count("/api/clip/"),
2676            0,
2677            "no per-id fan-out on rate-limit exhaustion"
2678        );
2679    }
2680
2681    #[test]
2682    fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
2683        // Batch-first: one `get_songs_by_ids` request (here returning nothing)
2684        // then four concurrent per-id fallbacks. All five share the 1 req/s
2685        // aggregate pacing, so from the first to the last reserved slot they span
2686        // ~4s, with a small tolerance for runtime scheduling jitter.
2687        const EXPECTED_SPAN: Duration = Duration::from_secs(4);
2688        const TOLERANCE: Duration = Duration::from_millis(50);
2689        let ids = ["a", "b", "c", "d"];
2690        let a =
2691            serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
2692                .to_string();
2693        let b =
2694            serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
2695                .to_string();
2696        let c =
2697            serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
2698                .to_string();
2699        let d =
2700            serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
2701                .to_string();
2702        let http = ScriptedHttp::new()
2703            .with_auth()
2704            .route_seq(
2705                "/api/feed/v3",
2706                vec![
2707                    Reply::status(429),
2708                    Reply::json(&one_clip_page("seed", None)),
2709                ],
2710            )
2711            .route("get_songs_by_ids", Reply::json(r#"{"clips":[]}"#))
2712            .route("/api/clip/a", Reply::json(&a))
2713            .route("/api/clip/b", Reply::json(&b))
2714            .route("/api/clip/c", Reply::json(&c))
2715            .route("/api/clip/d", Reply::json(&d));
2716        let clock = RecordingClock::new();
2717        let client = scripted_client(&http, clock.clone());
2718        pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
2719        let before = clock.sleeps().len();
2720
2721        let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
2722        assert_eq!(clips.len(), ids.len());
2723        let sleeps = clock.sleeps();
2724        let paced = &sleeps[before..];
2725        assert_eq!(
2726            paced.len(),
2727            ids.len() + 1,
2728            "one batch call plus four per-id"
2729        );
2730        let min = paced.iter().copied().min().unwrap();
2731        let max = paced.iter().copied().max().unwrap();
2732        let span = max.saturating_sub(min);
2733        // After the first 429, rate halves from 2 -> 1 req/s. Under shared slot
2734        // pacing, the batch call and the four per-id fallbacks are dispatched one
2735        // second apart in aggregate, so the first-to-last spacing is about four
2736        // seconds.
2737        assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
2738        assert!(span <= EXPECTED_SPAN + TOLERANCE);
2739    }
2740
2741    #[test]
2742    fn get_clip_parent_reads_the_parent_clip() {
2743        let parent = serde_json::json!({
2744            "id": "par", "title": "Ancestor", "status": "complete",
2745            "metadata": {"type": "gen"}
2746        })
2747        .to_string();
2748        let mut rules = auth_rules();
2749        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2750        let http = MockHttp::new(rules);
2751        let client = authed_client(&http);
2752
2753        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
2754        assert_eq!(clip.unwrap().id, "par");
2755    }
2756
2757    #[test]
2758    fn get_clip_parent_is_none_for_a_root() {
2759        let mut rules = auth_rules();
2760        rules.push(Rule::new(
2761            "/api/clips/parent",
2762            404,
2763            r#"{"detail": "no parent"}"#.to_string(),
2764        ));
2765        let http = MockHttp::new(rules);
2766        let client = authed_client(&http);
2767
2768        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2769        assert!(clip.is_none());
2770    }
2771
2772    #[test]
2773    fn get_clip_parent_is_none_for_a_200_no_id_root() {
2774        // The live "no parent" contract: HTTP 200 with a bodiless clip that has
2775        // no id (`{"is_public": false}`), not a 404. parse_clip gates on a
2776        // non-empty id, so it maps to Ok(None) rather than a bogus edge. Both
2777        // the bare and `{"clip": ...}`-wrapped encodings must behave the same.
2778        for body in [
2779            r#"{"is_public": false}"#,
2780            r#"{"clip": {"is_public": false}}"#,
2781        ] {
2782            let mut rules = auth_rules();
2783            rules.push(Rule::new("/api/clips/parent", 200, body.to_string()));
2784            let http = MockHttp::new(rules);
2785            let client = authed_client(&http);
2786
2787            let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2788            assert!(clip.is_none(), "200-no-id body {body:?} must map to None");
2789        }
2790    }
2791
2792    #[test]
2793    fn get_clip_parent_reads_the_reduced_user_prefixed_shape() {
2794        // The parent endpoint returns a reduced shape with user_-prefixed
2795        // identity keys; after the dual-identity mapper fix the parent Clip
2796        // carries a non-empty display_name/handle (regression pin for #220).
2797        let parent = serde_json::json!({
2798            "id": "00000000-0000-4000-8000-000000000020",
2799            "title": "Track 2",
2800            "is_public": false,
2801            "user_display_name": "Example Artist 4",
2802            "user_handle": "example-artist-1",
2803            "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
2804        })
2805        .to_string();
2806        let mut rules = auth_rules();
2807        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2808        let http = MockHttp::new(rules);
2809        let client = authed_client(&http);
2810
2811        let clip = pollster::block_on(client.get_clip_parent(&http, "child"))
2812            .unwrap()
2813            .expect("a parent clip with an id");
2814        assert_eq!(clip.id, "00000000-0000-4000-8000-000000000020");
2815        assert_eq!(clip.display_name, "Example Artist 4");
2816        assert_eq!(clip.handle, "example-artist-1");
2817        assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
2818    }
2819
2820    #[test]
2821    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
2822        // A transient 5xx must never be mistaken for "this clip is a root":
2823        // folding it into Ok(None) would fabricate a wrong external root and let
2824        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
2825        for status in [500u16, 503] {
2826            let mut rules = auth_rules();
2827            rules.push(Rule::new(
2828                "/api/clips/parent",
2829                status,
2830                r#"{"detail": "server error"}"#.to_string(),
2831            ));
2832            let http = MockHttp::new(rules);
2833            let client = authed_client(&http);
2834
2835            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
2836            assert!(
2837                matches!(result, Err(Error::Api(_))),
2838                "status {status} must propagate as an error, not Ok(None)"
2839            );
2840        }
2841    }
2842
2843    #[test]
2844    fn get_playlists_maps_entries_and_skips_missing_ids() {
2845        let page1 = serde_json::json!({
2846            "playlists": [
2847                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
2848                {"id": "", "name": "No Id", "num_total_results": 3},
2849                {"name": "Also No Id"}
2850            ]
2851        })
2852        .to_string();
2853        let mut rules = auth_rules();
2854        // Page 1 returns entries; page 2 is empty, ending pagination.
2855        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2856        rules.push(Rule::new(
2857            "/api/playlist/me?page=2",
2858            200,
2859            r#"{"playlists": []}"#.to_string(),
2860        ));
2861        let http = MockHttp::new(rules);
2862        let client = authed_client(&http);
2863
2864        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
2865        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
2866        assert_eq!(
2867            playlists[0],
2868            Playlist {
2869                id: "pl1".to_owned(),
2870                name: "Road Trip".to_owned(),
2871                num_clips: 12,
2872            }
2873        );
2874    }
2875
2876    #[test]
2877    fn get_playlists_defaults_a_missing_name_to_untitled() {
2878        let page1 = serde_json::json!({
2879            "playlists": [{"id": "pl9", "num_total_results": 1}]
2880        })
2881        .to_string();
2882        let mut rules = auth_rules();
2883        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2884        rules.push(Rule::new(
2885            "/api/playlist/me?page=2",
2886            200,
2887            r#"{"playlists": []}"#.to_string(),
2888        ));
2889        let http = MockHttp::new(rules);
2890        let client = authed_client(&http);
2891
2892        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
2893        assert_eq!(playlists[0].name, "Untitled");
2894    }
2895
2896    #[test]
2897    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
2898        // Members arrive wrapped under `clip`, in playlist order, already
2899        // non-trashed. Order is preserved and no downloadability filter is applied.
2900        let body = serde_json::json!({
2901            "num_total_results": 2,
2902            "playlist_clips": [
2903                {"clip": {
2904                    "id": "second", "title": "Second", "status": "complete",
2905                    "metadata": {"duration": 60.0, "type": "gen"}
2906                }},
2907                {"clip": {
2908                    "id": "first", "title": "First", "status": "complete",
2909                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
2910                }}
2911            ]
2912        })
2913        .to_string();
2914        let mut rules = auth_rules();
2915        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2916        let http = MockHttp::new(rules);
2917        let client = authed_client(&http);
2918
2919        let (clips, complete) =
2920            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2921        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
2922        assert_eq!(clips[0].id, "second");
2923        assert_eq!(clips[1].id, "first");
2924        assert!(
2925            complete,
2926            "returned == num_total_results is fully enumerated"
2927        );
2928    }
2929
2930    #[test]
2931    fn get_playlist_clips_short_page_is_not_complete() {
2932        // A page with fewer entries than num_total_results is not authoritative.
2933        let body = serde_json::json!({
2934            "num_total_results": 5,
2935            "playlist_clips": [
2936                {"clip": {
2937                    "id": "only", "title": "Only", "status": "complete",
2938                    "metadata": {"duration": 60.0, "type": "gen"}
2939                }}
2940            ]
2941        })
2942        .to_string();
2943        let mut rules = auth_rules();
2944        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2945        let http = MockHttp::new(rules);
2946        let client = authed_client(&http);
2947
2948        let (clips, complete) =
2949            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2950        assert_eq!(clips.len(), 1);
2951        assert!(!complete, "a short page is not fully enumerated");
2952    }
2953
2954    #[test]
2955    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
2956        let mut rules = auth_rules();
2957        rules.push(Rule::new(
2958            "/api/playlist/empty/",
2959            200,
2960            r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
2961        ));
2962        let http = MockHttp::new(rules);
2963        let client = authed_client(&http);
2964
2965        let (clips, complete) =
2966            pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
2967        assert!(clips.is_empty());
2968        assert!(
2969            complete,
2970            "an empty playlist reporting zero total is complete"
2971        );
2972    }
2973
2974    #[test]
2975    fn get_playlist_clips_missing_total_is_not_complete() {
2976        // A body without num_total_results cannot be verified as whole, so it is
2977        // never authoritative -- an empty or malformed page must not let a Mirror
2978        // area delete from it (D5).
2979        let mut rules = auth_rules();
2980        rules.push(Rule::new(
2981            "/api/playlist/pl1/",
2982            200,
2983            r#"{"playlist_clips": []}"#.to_string(),
2984        ));
2985        let http = MockHttp::new(rules);
2986        let client = authed_client(&http);
2987
2988        let (clips, complete) =
2989            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2990        assert!(clips.is_empty());
2991        assert!(!complete, "a missing total is never fully enumerated");
2992    }
2993
2994    #[test]
2995    fn get_playlist_clips_dropped_member_disarms_authority() {
2996        // A member whose clip carries no usable id is dropped by the empty-id
2997        // filter, so clips.len() < raw_len even when raw_len == num_total_results.
2998        // Both a missing `id` key and an empty-string `id` must disarm deletion
2999        // authority rather than silently arming a Mirror area on a short set.
3000        let missing_id = serde_json::json!({
3001            "num_total_results": 2,
3002            "playlist_clips": [
3003                {"clip": {
3004                    "id": "a", "title": "A", "status": "complete",
3005                    "metadata": {"duration": 60.0, "type": "gen"}
3006                }},
3007                {"clip": {
3008                    "title": "No Id", "status": "complete",
3009                    "metadata": {"duration": 30.0, "type": "gen"}
3010                }}
3011            ]
3012        })
3013        .to_string();
3014        let empty_id = serde_json::json!({
3015            "num_total_results": 2,
3016            "playlist_clips": [
3017                {"clip": {
3018                    "id": "a", "title": "A", "status": "complete",
3019                    "metadata": {"duration": 60.0, "type": "gen"}
3020                }},
3021                {"clip": {
3022                    "id": "", "title": "Empty Id", "status": "complete",
3023                    "metadata": {"duration": 30.0, "type": "gen"}
3024                }}
3025            ]
3026        })
3027        .to_string();
3028        for body in [missing_id, empty_id] {
3029            let mut rules = auth_rules();
3030            rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3031            let http = MockHttp::new(rules);
3032            let client = authed_client(&http);
3033
3034            let (clips, complete) =
3035                pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3036            assert_eq!(clips.len(), 1, "the member with no id is dropped");
3037            assert!(
3038                !complete,
3039                "a dropped member disarms authority even when raw_len == total"
3040            );
3041        }
3042    }
3043
3044    #[test]
3045    fn get_playlist_clips_over_count_is_not_complete() {
3046        // total=2 but three raw members (one with an empty id): clips.len()==2
3047        // matches the total, yet raw_len==3 does not. The two-conjunct gate must
3048        // reject this; a mis-simplification to `clips.len() == total` would wrongly
3049        // arm authority here.
3050        let body = serde_json::json!({
3051            "num_total_results": 2,
3052            "playlist_clips": [
3053                {"clip": {
3054                    "id": "a", "title": "A", "status": "complete",
3055                    "metadata": {"duration": 60.0, "type": "gen"}
3056                }},
3057                {"clip": {
3058                    "id": "b", "title": "B", "status": "complete",
3059                    "metadata": {"duration": 30.0, "type": "gen"}
3060                }},
3061                {"clip": {
3062                    "id": "", "title": "Empty Id", "status": "complete",
3063                    "metadata": {"duration": 45.0, "type": "gen"}
3064                }}
3065            ]
3066        })
3067        .to_string();
3068        let mut rules = auth_rules();
3069        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3070        let http = MockHttp::new(rules);
3071        let client = authed_client(&http);
3072
3073        let (clips, complete) =
3074            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3075        assert_eq!(clips.len(), 2, "the empty-id member is dropped");
3076        assert!(
3077            !complete,
3078            "raw_len (3) diverging from the total (2) is not authoritative"
3079        );
3080    }
3081
3082    #[test]
3083    fn get_playlist_clips_ignores_song_count() {
3084        // The detail reports song_count=0 while num_total_results=1 for the same
3085        // playlist; completeness must trust num_total_results, so a single-member
3086        // page reads as complete instead of being compared against song_count.
3087        let body = serde_json::json!({
3088            "num_total_results": 1,
3089            "song_count": 0,
3090            "playlist_clips": [
3091                {"clip": {
3092                    "id": "only", "title": "Only", "status": "complete",
3093                    "metadata": {"duration": 60.0, "type": "gen"}
3094                }}
3095            ]
3096        })
3097        .to_string();
3098        let mut rules = auth_rules();
3099        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3100        let http = MockHttp::new(rules);
3101        let client = authed_client(&http);
3102
3103        let (clips, complete) =
3104            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3105        assert_eq!(clips.len(), 1);
3106        assert!(
3107            complete,
3108            "completeness uses num_total_results, not song_count"
3109        );
3110    }
3111
3112    #[test]
3113    fn get_playlists_num_clips_ignores_song_count() {
3114        // song_count is unreliable across endpoints (15 in the listing, 0 in the
3115        // detail), so num_clips must come from num_total_results, never song_count.
3116        let page1 = serde_json::json!({
3117            "playlists": [
3118                {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
3119            ]
3120        })
3121        .to_string();
3122        let mut rules = auth_rules();
3123        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3124        rules.push(Rule::new(
3125            "/api/playlist/me?page=2",
3126            200,
3127            r#"{"playlists": []}"#.to_string(),
3128        ));
3129        let http = MockHttp::new(rules);
3130        let client = authed_client(&http);
3131
3132        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3133        assert_eq!(
3134            playlists[0].num_clips, 15,
3135            "num_clips reads num_total_results, not song_count"
3136        );
3137    }
3138
3139    #[test]
3140    fn get_playlists_dedupes_a_page_ignoring_server() {
3141        // A server that ignores `page` returns the same non-empty body for every
3142        // page, so the empty-page terminator never fires and MAX_PAGES bounds the
3143        // loop. Dedupe-by-id keeps the result to the true unique set instead of
3144        // MAX_PAGES copies.
3145        let same_body = serde_json::json!({
3146            "playlists": [
3147                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
3148                {"id": "pl2", "name": "Chill", "num_total_results": 7}
3149            ]
3150        })
3151        .to_string();
3152        let mut rules = auth_rules();
3153        rules.push(Rule::new("/api/playlist/me", 200, same_body));
3154        let http = MockHttp::new(rules);
3155        let client = authed_client(&http);
3156
3157        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3158        assert_eq!(
3159            playlists.len(),
3160            2,
3161            "duplicates from a page-ignoring server are collapsed"
3162        );
3163        assert_eq!(playlists[0].id, "pl1");
3164        assert_eq!(playlists[1].id, "pl2");
3165    }
3166
3167    #[test]
3168    fn get_playlist_clips_preserves_array_order_over_created_at() {
3169        // relative_index ascends with array order while the wrapper created_at
3170        // values are non-monotonic. Members must stay in array order: the parser
3171        // never sorts by created_at (or any timestamp).
3172        let body = serde_json::json!({
3173            "num_total_results": 3,
3174            "playlist_clips": [
3175                {"clip": {
3176                    "id": "a", "title": "A", "status": "complete",
3177                    "metadata": {"duration": 60.0, "type": "gen"}
3178                }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
3179                {"clip": {
3180                    "id": "b", "title": "B", "status": "complete",
3181                    "metadata": {"duration": 30.0, "type": "gen"}
3182                }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
3183                {"clip": {
3184                    "id": "c", "title": "C", "status": "complete",
3185                    "metadata": {"duration": 45.0, "type": "gen"}
3186                }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
3187            ]
3188        })
3189        .to_string();
3190        let mut rules = auth_rules();
3191        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3192        let http = MockHttp::new(rules);
3193        let client = authed_client(&http);
3194
3195        let (clips, complete) =
3196            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3197        assert_eq!(
3198            clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
3199            ["a", "b", "c"],
3200            "array order is preserved despite non-monotonic created_at"
3201        );
3202        assert!(complete, "three intact members equal the declared total");
3203    }
3204
3205    /// A stems page body: each stem is a full clip object whose title carries
3206    /// the label in a trailing parenthetical, as the live endpoint returns.
3207    fn stem_page(stems: &[(&str, &str, &str)]) -> String {
3208        let entries: Vec<Value> = stems
3209            .iter()
3210            .map(|(id, label, url)| {
3211                serde_json::json!({
3212                    "id": id,
3213                    "title": format!("My Song ({label})"),
3214                    "status": "complete",
3215                    "audio_url": url,
3216                })
3217            })
3218            .collect();
3219        serde_json::json!({ "stems": entries }).to_string()
3220    }
3221
3222    /// The page-count body for `GET /api/clip/{id}/stems/pages`.
3223    fn stem_pages(pages: u32) -> String {
3224        serde_json::json!({ "pages": pages }).to_string()
3225    }
3226
3227    #[test]
3228    fn list_stems_drains_all_declared_pages_and_is_authoritative() {
3229        // Two 0-indexed pages, both drained: the stems concatenate in order and
3230        // the listing is authoritative (it declared its pages and held stems).
3231        let http = ScriptedHttp::new()
3232            .with_auth()
3233            .route("stems/pages", Reply::json(&stem_pages(2)))
3234            .route(
3235                "stems?page=0",
3236                Reply::json(&stem_page(&[
3237                    ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
3238                    ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
3239                ])),
3240            )
3241            .route(
3242                "stems?page=1",
3243                Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
3244            );
3245        let client = scripted_client(&http, RecordingClock::new());
3246
3247        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3248        assert_eq!(stems.len(), 3);
3249        assert_eq!(stems[0].id, "s1");
3250        assert_eq!(stems[0].label, "Vocals");
3251        assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
3252        assert_eq!(stems[2].label, "Bass");
3253        assert!(
3254            complete,
3255            "a fully drained listing that returned stems is authoritative"
3256        );
3257    }
3258
3259    #[test]
3260    fn list_stems_zero_pages_is_indeterminate_never_empty() {
3261        // A clip with no stems answers `{"pages": 0}`. That must NOT be read as an
3262        // authoritative empty set, or it could delete local stems.
3263        let http = ScriptedHttp::new()
3264            .with_auth()
3265            .route("stems/pages", Reply::json(&stem_pages(0)));
3266        let client = scripted_client(&http, RecordingClock::new());
3267
3268        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3269        assert!(stems.is_empty());
3270        assert!(
3271            !complete,
3272            "an empty listing is indeterminate, so existing stems are kept"
3273        );
3274    }
3275
3276    #[test]
3277    fn list_stems_missing_page_count_is_indeterminate() {
3278        // A `400`/`404` on the page-count endpoint (Suno's "no stems" answer) is
3279        // indeterminate, never an authoritative empty set.
3280        for status in [400u16, 404] {
3281            let http = ScriptedHttp::new()
3282                .with_auth()
3283                .route("stems/pages", Reply::status(status));
3284            let client = scripted_client(&http, RecordingClock::new());
3285            let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3286            assert!(stems.is_empty(), "status {status}");
3287            assert!(!complete, "status {status} is indeterminate, not empty");
3288        }
3289    }
3290
3291    #[test]
3292    fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
3293        // A `5xx` whose body happens to contain "Invalid page" must NOT be
3294        // classified as "no stems": body-text matching would misclassify it.
3295        // Only a genuine `400` status triggers the no-stems path.
3296        let http = ScriptedHttp::new()
3297            .with_auth()
3298            .route("stems/pages", Reply::with_body(500, "Invalid page"));
3299        let client = scripted_client(&http, RecordingClock::new());
3300
3301        let result = pollster::block_on(client.list_stems(&http, "clip1"));
3302        assert!(
3303            result.is_err(),
3304            "a 5xx is a transient error, never 'no stems'"
3305        );
3306    }
3307
3308    #[test]
3309    fn list_stems_page_error_mid_enumeration_propagates() {
3310        // A transient 5xx on a page mid-drain is indeterminate, not an end: it
3311        // surfaces as an error rather than a (partial) authoritative set, so the
3312        // caller keeps existing stems.
3313        let http = ScriptedHttp::new()
3314            .with_auth()
3315            .route("stems/pages", Reply::json(&stem_pages(2)))
3316            .route(
3317                "stems?page=0",
3318                Reply::json(&stem_page(&[(
3319                    "s1",
3320                    "Vocals",
3321                    "https://cdn1.suno.ai/s1.mp3",
3322                )])),
3323            )
3324            .route("stems?page=1", Reply::status(500));
3325        let client = scripted_client(&http, RecordingClock::new());
3326
3327        let result = pollster::block_on(client.list_stems(&http, "clip1"));
3328        assert!(result.is_err(), "a 5xx page is not a clean drain");
3329    }
3330
3331    #[test]
3332    fn list_stems_over_max_pages_is_truncated_never_authoritative() {
3333        // A clip that declares more pages than the `MAX_PAGES` cap can only be
3334        // drained partially, so even though the fetched pages hold stems the
3335        // listing is TRUNCATED and must not be authoritative: its un-fetched
3336        // stems on pages beyond the cap would otherwise be delete-reconciled.
3337        let http = ScriptedHttp::new()
3338            .with_auth()
3339            .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
3340            .route(
3341                "stems?page=",
3342                Reply::json(&stem_page(&[(
3343                    "s1",
3344                    "Vocals",
3345                    "https://cdn1.suno.ai/s1.mp3",
3346                )])),
3347            );
3348        let client = scripted_client(&http, RecordingClock::new());
3349
3350        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3351        assert!(!stems.is_empty(), "the fetched pages still yield stems");
3352        assert!(
3353            !complete,
3354            "a listing declaring more than MAX_PAGES is truncated, never authoritative"
3355        );
3356    }
3357
3358    #[test]
3359    fn parse_stems_page_maps_full_clips_and_skips_idless() {
3360        // A stem is a full clip: id, label from the title parenthetical, and the
3361        // public CDN MP3 url.
3362        let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
3363        let stems = parse_stems_page(page.as_bytes());
3364        assert_eq!(stems.len(), 1);
3365        assert_eq!(stems[0].id, "x");
3366        assert_eq!(stems[0].label, "Backing Vocals");
3367        assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
3368        // An entry with no id cannot be keyed or WAV-rendered and is dropped.
3369        let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
3370        assert!(parse_stems_page(no_id).is_empty());
3371        // A stem with an id but no audio_url still resolves a deterministic CDN
3372        // url from its id, so it remains downloadable.
3373        let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
3374        let recovered = parse_stems_page(no_url);
3375        assert_eq!(recovered.len(), 1);
3376        assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
3377        // Malformed JSON never panics; it yields no stems.
3378        assert!(parse_stems_page(b"not json").is_empty());
3379    }
3380
3381    #[test]
3382    fn list_stems_labels_the_inferred_populated_page_from_the_stem_group() {
3383        // The populated `/stems` shape was never captured for this account, so
3384        // it is inferred: each stem is a full clip whose structured
3385        // `metadata.stem_type_group_name` (underscore form) is the label, even
3386        // when the title carries no parenthetical. This pins the normaliser and
3387        // the group-over-title preference against the inferred fixture.
3388        let page = serde_json::json!({
3389            "stems": [{
3390                "id": "stem-bv",
3391                "title": "Track 30",
3392                "status": "complete",
3393                "audio_url": "https://cdn1.suno.ai/stem-bv.mp3",
3394                "metadata": {
3395                    "stem_from_id": "source-074",
3396                    "stem_task": "twelve",
3397                    "stem_type_id": 91.0,
3398                    "stem_type_group_name": "Backing_Vocals"
3399                }
3400            }]
3401        })
3402        .to_string();
3403        let http = ScriptedHttp::new()
3404            .with_auth()
3405            .route("stems/pages", Reply::json(&stem_pages(1)))
3406            .route("stems?page=0", Reply::json(&page));
3407        let client = scripted_client(&http, RecordingClock::new());
3408
3409        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3410        assert_eq!(stems.len(), 1);
3411        assert_eq!(stems[0].id, "stem-bv");
3412        assert_eq!(
3413            stems[0].label, "Backing Vocals",
3414            "the underscore group name is normalised, not the empty title parenthetical"
3415        );
3416        assert_eq!(stems[0].url, "https://cdn1.suno.ai/stem-bv.mp3");
3417        assert!(
3418            complete,
3419            "a drained listing that returned a stem is authoritative"
3420        );
3421    }
3422
3423    #[test]
3424    fn stem_label_prefers_the_normalised_group_over_the_title() {
3425        // The structured group name wins and its underscore form is normalised.
3426        let grouped = Clip {
3427            title: "Track 30".to_owned(),
3428            stem_type_group_name: "Backing_Vocals".to_owned(),
3429            ..Default::default()
3430        };
3431        assert_eq!(stem_label(&grouped), "Backing Vocals");
3432        // It still wins over a present title parenthetical (strictly more
3433        // reliable and language-stable than title scraping).
3434        let both = Clip {
3435            title: "My Song (Guitar)".to_owned(),
3436            stem_type_group_name: "Vocals".to_owned(),
3437            ..Default::default()
3438        };
3439        assert_eq!(stem_label(&both), "Vocals");
3440        // No group name: fall back to the title parenthetical.
3441        let titled = Clip {
3442            title: "My Song (Drums)".to_owned(),
3443            ..Default::default()
3444        };
3445        assert_eq!(stem_label(&titled), "Drums");
3446        // Neither present: empty, so the caller falls back to the stem id.
3447        let bare = Clip {
3448            title: "Track 31".to_owned(),
3449            ..Default::default()
3450        };
3451        assert_eq!(stem_label(&bare), "");
3452    }
3453
3454    #[test]
3455    fn parse_stem_page_count_reads_pages_field() {
3456        assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
3457        assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
3458        // Missing, negative, or non-numeric pages read as 0 (indeterminate).
3459        assert_eq!(parse_stem_page_count(br#"{}"#), 0);
3460        assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
3461        assert_eq!(parse_stem_page_count(b"not json"), 0);
3462    }
3463
3464    #[test]
3465    fn stem_label_from_title_extracts_trailing_parenthetical() {
3466        assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
3467        assert_eq!(
3468            stem_label_from_title("A (b) Song (Backing Vocals)"),
3469            "Backing Vocals"
3470        );
3471        assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
3472        // No parenthetical: empty, so the caller falls back to the stem id.
3473        assert_eq!(stem_label_from_title("My Song"), "");
3474        assert_eq!(stem_label_from_title(""), "");
3475    }
3476
3477    #[test]
3478    fn post_allow_list_permits_only_feed_and_wav_render() {
3479        assert!(post_path_allowed(FEED_V3_PATH));
3480        assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
3481        // No generation endpoint is on the list.
3482        assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
3483        assert!(!post_path_allowed("/api/gen/abc123/separate"));
3484        // Path traversal or extra segments can't smuggle a match.
3485        assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
3486        assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
3487        // The stems endpoints are GET-only and never on the POST allow-list.
3488        assert!(!post_path_allowed("/api/clip/x/stems/pages"));
3489        assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
3490    }
3491
3492    #[test]
3493    fn api_request_refuses_a_post_off_the_allow_list() {
3494        // The single POST chokepoint rejects an off-list POST before the wire, so
3495        // a credit-spending endpoint can never be reached by accident.
3496        let http = MockHttp::new(auth_rules());
3497        let client = authed_client(&http);
3498        let err = pollster::block_on(client.api_request(
3499            &http,
3500            Method::Post,
3501            "/api/gen/x/stem_task",
3502            b"{}".to_vec(),
3503        ))
3504        .unwrap_err();
3505        assert!(matches!(err, Error::Refused(_)));
3506    }
3507}