use std::path::Component;
use proptest::collection::vec;
use proptest::prelude::*;
use serde_json::{Map, Value};
use crate::auth::ClerkAuth;
use crate::client::SunoClient;
use crate::config::Config;
use crate::lineage::LineageContext;
use crate::model::Clip;
use crate::naming::{DEFAULT_TEMPLATE, NamingConfig, NamingRequest, render_clip_name};
use crate::select::RecencySpec;
use crate::testutil::{ChaosHttp, Outcome, RecordingClock};
fn arb_json() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i64>().prop_map(|n| Value::Number(n.into())),
any::<String>().prop_map(Value::String),
];
leaf.prop_recursive(4, 48, 8, |inner| {
prop_oneof![
vec(inner.clone(), 0..6).prop_map(Value::Array),
vec(("[a-zA-Z0-9_]{0,8}", inner), 0..6).prop_map(|pairs| {
Value::Object(pairs.into_iter().collect::<Map<String, Value>>())
}),
]
})
}
fn arb_clip() -> impl Strategy<Value = Clip> {
(
any::<String>(),
any::<String>(),
any::<String>(),
any::<String>(),
any::<String>(),
any::<String>(),
)
.prop_map(
|(id, title, display_name, handle, album_title, root_ancestor_id)| Clip {
id,
title,
display_name,
handle,
album_title,
root_ancestor_id,
..Default::default()
},
)
}
fn arb_template() -> impl Strategy<Value = String> {
let segment = prop_oneof![
Just("{creator}".to_string()),
Just("{handle}".to_string()),
Just("{album}".to_string()),
Just("{title}".to_string()),
Just("{id}".to_string()),
Just(".".to_string()),
Just("..".to_string()),
Just("lit".to_string()),
Just(String::new()),
];
prop_oneof![
Just(DEFAULT_TEMPLATE.to_string()),
any::<String>(),
vec(segment, 0..6).prop_map(|segs| segs.join("/")),
]
}
proptest! {
#[test]
fn clip_from_json_never_panics(value in arb_json()) {
let _ = Clip::from_json(&value);
}
#[test]
fn list_clips_survives_arbitrary_feed_bytes(body in any::<Vec<u8>>()) {
let http = ChaosHttp::new()
.with_auth()
.program("/api/feed/v3", vec![Outcome::ok(body)]);
let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
let _ = pollster::block_on(client.list_clips(&http, false, Some(3)));
}
#[test]
fn list_clips_survives_arbitrary_feed_json(value in arb_json()) {
let body = serde_json::to_vec(&value).expect("arb_json is serialisable");
let http = ChaosHttp::new()
.with_auth()
.program("/api/feed/v3", vec![Outcome::ok(body)]);
let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
let _ = pollster::block_on(client.list_clips(&http, true, Some(3)));
}
#[test]
fn recency_spec_parse_never_panics(spec in any::<String>()) {
let _ = RecencySpec::parse(&spec);
}
#[test]
fn config_from_toml_never_panics(text in any::<String>()) {
let _ = Config::from_toml(&text);
}
#[test]
fn render_clip_name_is_always_a_safe_relative_path(
clip in arb_clip(),
template in arb_template(),
max_component_len in 1usize..120,
) {
let config = NamingConfig {
template,
max_component_len,
..Default::default()
};
let lineage = LineageContext::own_root(&clip);
let request = NamingRequest { clip: &clip, lineage: &lineage };
let rendered = render_clip_name(request, &config);
prop_assert!(rendered.relative_path.is_relative(), "the path must be relative");
prop_assert!(
rendered.relative_path.components().count() >= 1,
"the path must have at least one component",
);
for component in rendered.relative_path.components() {
match component {
Component::Normal(part) => {
let text = part.to_string_lossy();
prop_assert!(!text.is_empty(), "no empty component");
prop_assert!(
!text.contains('/') && !text.contains('\\'),
"no separator inside a component: {text:?}",
);
prop_assert_ne!(text.as_ref(), ".", "no current-dir component");
prop_assert_ne!(text.as_ref(), "..", "no parent-dir component");
}
other => prop_assert!(false, "unexpected non-normal component: {other:?}"),
}
}
}
}
#[test]
fn recency_spec_parses_known_forms_and_rejects_garbage() {
assert!(matches!(
RecencySpec::parse("last-run"),
Ok(RecencySpec::LastRun)
));
assert!(matches!(RecencySpec::parse("7d"), Ok(RecencySpec::Relative(s)) if s == 7 * 86_400));
assert!(matches!(
RecencySpec::parse("2w"),
Ok(RecencySpec::Relative(s)) if s == 2 * 7 * 86_400
));
assert!(
RecencySpec::parse("12x").is_err(),
"unknown unit must error"
);
assert!(
RecencySpec::parse("notaspec").is_err(),
"non-numeric must error"
);
assert!(RecencySpec::parse("").is_err(), "empty must error");
}