use mockito::Server;
use std::time::Duration;
use typecast_rust::{
ClientConfig, TypecastClient, TypecastError, CLONING_MAX_FILE_SIZE, NAME_MAX_LENGTH,
NAME_MIN_LENGTH,
};
fn make_client(server: &Server) -> TypecastClient {
let config = ClientConfig::new("test-api-key")
.base_url(server.url())
.timeout(Duration::from_secs(5));
TypecastClient::new(config).expect("client builds")
}
fn make_proxy_client_without_api_key(server: &Server) -> TypecastClient {
let config = ClientConfig {
api_key: String::new(),
base_url: server.url(),
timeout: Duration::from_secs(5),
};
TypecastClient::new(config).expect("client builds")
}
fn small_wav() -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"RIFF");
buf.extend_from_slice(&36u32.to_le_bytes()); buf.extend_from_slice(b"WAVE");
buf.extend_from_slice(b"fmt ");
buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&1u16.to_le_bytes()); buf.extend_from_slice(&44100u32.to_le_bytes()); buf.extend_from_slice(&88200u32.to_le_bytes()); buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&16u16.to_le_bytes()); buf.extend_from_slice(b"data");
buf.extend_from_slice(&0u32.to_le_bytes()); buf
}
fn clone_response_json() -> &'static str {
r#"{"voice_id":"cv_abc123","name":"My Voice","model":"ssfm-v30"}"#
}
#[tokio::test]
async fn clone_voice_returns_custom_voice() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/v1/voices/clone")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(clone_response_json())
.create_async()
.await;
let client = make_client(&server);
let result = client
.clone_voice(small_wav(), "sample.wav", "My Voice", "ssfm-v30")
.await;
let voice = result.expect("clone_voice should succeed");
assert_eq!(voice.voice_id, "cv_abc123");
assert_eq!(voice.name, "My Voice");
assert_eq!(voice.model, "ssfm-v30");
let _ = format!("{voice:?}");
let cloned = voice.clone();
assert_eq!(voice, cloned);
}
#[tokio::test]
async fn clone_voice_sends_multipart_body() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/v1/voices/clone")
.match_header(
"content-type",
mockito::Matcher::Regex("^multipart/form-data".into()),
)
.match_body(mockito::Matcher::AllOf(vec![
mockito::Matcher::Regex("My Clone".into()),
mockito::Matcher::Regex("ssfm-v21".into()),
mockito::Matcher::Regex("sample.wav".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"voice_id":"cv_xyz","name":"My Clone","model":"ssfm-v21"}"#)
.create_async()
.await;
let client = make_client(&server);
let result = client
.clone_voice(small_wav(), "sample.wav", "My Clone", "ssfm-v21")
.await;
assert!(result.is_ok(), "expected Ok, got {result:?}");
_m.assert_async().await;
}
#[tokio::test]
async fn clone_voice_sets_supported_mime_types() {
let cases = [
("sample.mp3", "audio/mpeg"),
("sample.ogg", "audio/ogg"),
("sample.flac", "audio/flac"),
("sample.m4a", "audio/mp4"),
("sample.bin", "application/octet-stream"),
];
for (filename, expected_mime) in cases {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/v1/voices/clone")
.match_body(mockito::Matcher::Regex(expected_mime.into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(clone_response_json())
.create_async()
.await;
let client = make_client(&server);
let result = client
.clone_voice(small_wav(), filename, "My Voice", "ssfm-v30")
.await;
assert!(result.is_ok(), "expected Ok for {filename}, got {result:?}");
_m.assert_async().await;
}
}
#[tokio::test]
async fn clone_voice_returns_err_on_http_error() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/v1/voices/clone")
.with_status(422)
.with_header("content-type", "application/json")
.with_body(r#"{"detail":"invalid audio format"}"#)
.create_async()
.await;
let client = make_client(&server);
let err = client
.clone_voice(small_wav(), "sample.wav", "My Voice", "ssfm-v30")
.await
.expect_err("should fail on 422");
assert!(
matches!(err, TypecastError::ValidationError { .. }),
"expected ValidationError, got {err:?}"
);
}
#[tokio::test]
async fn clone_voice_returns_err_on_malformed_success_json() {
let mut server = Server::new_async().await;
let _m = server
.mock("POST", "/v1/voices/clone")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("{")
.create_async()
.await;
let client = make_client(&server);
let err = client
.clone_voice(small_wav(), "sample.wav", "My Voice", "ssfm-v30")
.await
.expect_err("should fail on malformed JSON");
assert!(
matches!(err, TypecastError::HttpError(_)),
"expected HttpError, got {err:?}"
);
}
#[tokio::test]
async fn clone_voice_returns_err_on_transport_failure() {
let config = ClientConfig::new("test-api-key")
.base_url("http://127.0.0.1:9")
.timeout(Duration::from_millis(100));
let client = TypecastClient::new(config).expect("client builds");
let err = client
.clone_voice(small_wav(), "sample.wav", "My Voice", "ssfm-v30")
.await
.expect_err("should fail when transport cannot connect");
assert!(
matches!(err, TypecastError::HttpError(_)),
"expected HttpError, got {err:?}"
);
}
#[tokio::test]
async fn clone_voice_rejects_oversized_audio() {
let oversized = vec![0u8; CLONING_MAX_FILE_SIZE + 1];
let server = Server::new_async().await;
let client = make_client(&server);
let err = client
.clone_voice(oversized, "big.wav", "Voice", "ssfm-v30")
.await
.expect_err("should reject oversized audio");
assert!(
matches!(err, TypecastError::ValidationError { .. }),
"expected ValidationError, got {err:?}"
);
assert!(
err.to_string().contains("25MB"),
"error message should mention '25MB', got: {err}"
);
}
#[tokio::test]
async fn clone_voice_rejects_bad_name_length() {
let server = Server::new_async().await;
let client = make_client(&server);
let audio = small_wav();
let err_empty = client
.clone_voice(audio.clone(), "sample.wav", "", "ssfm-v30")
.await
.expect_err("empty name should fail");
assert!(
matches!(err_empty, TypecastError::ValidationError { .. }),
"expected ValidationError for empty name, got {err_empty:?}"
);
assert!(
err_empty
.to_string()
.contains(&format!("{}-{}", NAME_MIN_LENGTH, NAME_MAX_LENGTH)),
"error should mention valid range, got: {err_empty}"
);
let long_name = "a".repeat(NAME_MAX_LENGTH + 1);
let err_long = client
.clone_voice(audio.clone(), "sample.wav", &long_name, "ssfm-v30")
.await
.expect_err("31-char name should fail");
assert!(
matches!(err_long, TypecastError::ValidationError { .. }),
"expected ValidationError for long name, got {err_long:?}"
);
assert!(
err_long
.to_string()
.contains(&format!("{}-{}", NAME_MIN_LENGTH, NAME_MAX_LENGTH)),
"error should mention valid range, got: {err_long}"
);
}
#[tokio::test]
async fn delete_voice_returns_ok_on_204() {
let mut server = Server::new_async().await;
let _m = server
.mock("DELETE", "/v1/voices/cv_abc123")
.with_status(204)
.create_async()
.await;
let client = make_client(&server);
let result = client.delete_voice("cv_abc123").await;
assert!(result.is_ok(), "expected Ok(()), got {result:?}");
}
#[tokio::test]
async fn delete_voice_with_proxy_base_url_omits_auth_header_without_api_key() {
let mut server = Server::new_async().await;
let _m = server
.mock("DELETE", "/v1/voices/cv_abc123")
.match_header("x-api-key", mockito::Matcher::Missing)
.with_status(204)
.create_async()
.await;
let client = make_proxy_client_without_api_key(&server);
client.delete_voice("cv_abc123").await.unwrap();
}
#[tokio::test]
async fn delete_voice_returns_err_on_404() {
let mut server = Server::new_async().await;
let _m = server
.mock("DELETE", "/v1/voices/missing")
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"detail":"voice not found"}"#)
.create_async()
.await;
let client = make_client(&server);
let err = client
.delete_voice("missing")
.await
.expect_err("should fail on 404");
assert!(err.is_not_found(), "expected NotFound, got {err:?}");
}
#[tokio::test]
async fn delete_voice_returns_err_on_transport_failure() {
let config = ClientConfig::new("test-api-key")
.base_url("http://127.0.0.1:9")
.timeout(Duration::from_millis(100));
let client = TypecastClient::new(config).expect("client builds");
let err = client
.delete_voice("uc_xxx")
.await
.expect_err("should fail when transport cannot connect");
assert!(
matches!(err, TypecastError::HttpError(_)),
"expected HttpError, got {err:?}"
);
}