use super::*;
#[test]
fn list_clips_authenticates_then_reads_the_feed() {
let client_body = serde_json::json!({
"response": {
"last_active_session_id": "s",
"sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
}
})
.to_string();
let http = MockHttp::new(vec![
Rule::new(
"/v1/client/sessions/",
200,
r#"{"jwt": "a.b.c"}"#.to_string(),
),
Rule::new("/v1/client", 200, client_body),
Rule::new("/api/feed/v3", 200, feed_body()),
]);
let auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(&http)).unwrap();
let client = SunoClient::new(auth, RecordingClock::new());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0].id, "a");
assert!(complete);
}
#[test]
fn list_clips_reports_incomplete_when_paging_is_capped() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/feed/v3",
200,
serde_json::json!({
"has_more": true,
"next_cursor": "cur1",
"clips": [{
"id": "a", "title": "Song A", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3",
"metadata": {"type": "gen"}
}]
})
.to_string(),
));
let http = MockHttp::new(rules);
let client = authed_client(&http);
let (_clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
}
#[test]
fn list_clips_retries_a_rate_limited_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![Reply::status(429), Reply::json(&feed_body())],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert!(complete);
assert_eq!(http.count("/api/feed/v3"), 2);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
}
#[test]
fn list_clips_honours_retry_after_on_a_throttled_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::status(429).with_retry_after(7),
Reply::json(&feed_body()),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, _complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
}
#[test]
fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::status(429),
Reply::json(&one_clip_page("b", None)),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
let bodies = http.bodies();
let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
assert_eq!(retried["cursor"], "cur1");
assert_eq!(after_retry["cursor"], "cur1");
}
#[test]
fn list_clips_threads_the_cursor_across_pages() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&one_clip_page("b", None)),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
let bodies = http.bodies();
let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
assert_eq!(feed_bodies.len(), 2);
let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
assert!(page1.get("cursor").is_none());
assert_eq!(page2["cursor"], "cur1");
}
#[test]
fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
let page = serde_json::json!({
"has_more": true,
"clips": [{
"id": "a", "title": "Song", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
}]
})
.to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
assert_eq!(clips.len(), 1);
assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
}
#[test]
fn list_clips_is_incomplete_when_has_more_is_missing() {
let page = serde_json::json!({
"clips": [{
"id": "a", "title": "Song", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
}]
})
.to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
assert_eq!(clips.len(), 1);
assert_eq!(http.count("/api/feed/v3"), 1);
}
#[test]
fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::status(500),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let result = pollster::block_on(client.list_clips(&http, false, None));
assert!(matches!(result, Err(Error::Api(_))));
}
#[test]
fn list_clips_is_complete_on_an_empty_drained_feed() {
let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert!(clips.is_empty());
}
#[test]
fn list_clips_flags_filter_loss_on_a_drained_feed() {
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&feed_body()));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, any_filtered) =
pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert!(any_filtered);
assert_eq!(clips.len(), 1);
}
#[test]
fn list_clips_ors_filter_loss_across_pages() {
let page2 = serde_json::json!({
"has_more": false,
"clips": [
{"id": "e", "status": "complete", "metadata": {"type": "gen"}},
{"id": "f", "status": "streaming", "metadata": {}}
]
})
.to_string();
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&page2),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, any_filtered) =
pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert!(any_filtered);
assert_eq!(clips.len(), 2);
}
#[test]
fn list_clips_liked_scope_sends_the_liked_filter() {
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&feed_body()));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
let bodies = http.bodies();
let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
let value: Value = serde_json::from_str(feed_body).unwrap();
assert_eq!(value["filters"]["liked"], "True");
assert_eq!(value["filters"]["trashed"], "False");
}
#[test]
fn list_clips_does_not_pace_an_unthrottled_walk() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&one_clip_page("e", None)),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
assert_eq!(http.count("/api/feed/v3"), 2);
assert!(clock.sleeps().is_empty());
}
#[test]
fn list_clips_slows_its_pace_after_a_throttled_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::status(429),
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&one_clip_page("e", None)),
],
);
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let (clips, complete, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
assert_eq!(
clock.sleeps(),
vec![Duration::from_secs(5), Duration::from_secs(1)]
);
}
#[test]
fn list_clips_gives_up_after_max_retries() {
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::status(429));
let clock = RecordingClock::new();
let client = scripted_client(&http, clock.clone());
let result = pollster::block_on(client.list_clips(&http, false, None));
assert!(matches!(result, Err(Error::RateLimited { .. })));
let budget = crate::consts::API_MAX_RETRIES as usize;
assert_eq!(clock.sleeps().len(), budget);
assert_eq!(http.count("/api/feed/v3"), budget + 1);
}