Files
audible-util/tests/integration.rs
Ondra-Kavan f8cca3e848 feat: improve output-path handling and error messages
- If --output-path is a directory, output file uses default naming in that directory
- If --output-path is a file, use as-is
- Improved directory existence/writability checks and error messages
- Updated CLI help and documentation to describe new behavior
- Cleaned up unused imports and resolved all warnings
- Ensured ffmpeg failures are clearly reported to the user
2025-07-18 22:39:09 +02:00

401 lines
13 KiB
Rust

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::NamedTempFile;
use std::fs::File;
use std::io::Write;
use std::env;
use std::os::unix::fs::PermissionsExt;
// Sample valid voucher JSON
const VALID_VOUCHER: &str = r#"{
"content_license": {
"acr": "acr",
"asin": "asin",
"content_metadata": {
"content_reference": {
"acr": "acr",
"asin": "asin",
"codec": "codec",
"content_format": "format",
"content_size_in_bytes": 123,
"file_version": "1",
"marketplace": "market",
"sku": "sku",
"tempo": "tempo",
"version": "v1"
},
"content_url": { "offline_url": "url" },
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"drm_type": "drm",
"granted_right": "right",
"license_id": "id",
"license_response": {
"key": "key",
"iv": "iv",
"rules": [{
"parameters": [{
"expire_date": "date",
"type": "type"
}],
"name": "rule"
}]
},
"license_response_type": "type",
"message": "msg",
"playback_info": {
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"preview": false,
"request_id": "req",
"requires_ad_supported_playback": false,
"status_code": "ok",
"voucher_id": "vid"
},
"response_groups": ["group"]
}"#;
// Sample valid ffprobe JSON
const VALID_FFPROBE: &str = r#"{
"format": {
"filename": "file.aaxc",
"nb_streams": 1,
"nb_programs": 0,
"nb_stream_groups": 0,
"format_name": "aax",
"format_long_name": "Audible AAX",
"start_time": "0",
"duration": "100",
"size": "1000",
"bit_rate": "128000",
"probe_score": 100,
"tags": {
"major_brand": "brand",
"minor_version": "1",
"compatible_brands": "brand",
"creation_time": "now",
"genre": "genre",
"title": "title",
"artist": "artist",
"album_artist": "album_artist",
"album": "album",
"comment": "comment",
"copyright": "copyright",
"date": "2020"
}
}
}"#;
fn write_temp_file(contents: &str, suffix: &str) -> NamedTempFile {
use tempfile::Builder;
let mut file = Builder::new()
.suffix(suffix)
.tempfile()
.expect("temp file");
file.write_all(contents.as_bytes()).expect("write temp");
file
}
#[test]
fn test_missing_required_argument() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.assert()
.failure()
.stderr(predicate::str::contains("USAGE"));
}
#[test]
fn test_invalid_input_file() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg("nonexistent.aaxc");
cmd.arg("--voucher-path").arg("nonexistent.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Could not get file stem"));
}
#[test]
fn test_input_file_missing() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg("doesnotexist.aaxc");
cmd.arg("--voucher-path").arg("doesnotexist.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file does not exist"));
}
#[test]
fn test_input_file_wrong_extension() {
let file = write_temp_file("", ".mp3");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file does not have a .aaxc extension"));
}
#[test]
fn test_input_file_not_readable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Set file to 000 permissions (unreadable)
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file is not readable"));
// Restore permissions for cleanup
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_voucher_file_missing() {
let file = write_temp_file("", ".aaxc");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg("doesnotexist.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Voucher file does not exist"));
}
#[test]
fn test_voucher_file_not_readable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
std::fs::set_permissions(voucher.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Voucher file is not readable"));
std::fs::set_permissions(voucher.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_output_directory_missing() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let out_path = std::path::Path::new("nonexistent_dir/output.mp3");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-path").arg(out_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Output directory does not exist"));
}
#[test]
fn test_output_directory_not_writable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let tempdir = tempfile::tempdir().unwrap();
let unwritable_dir = tempdir.path().join("unwritable");
std::fs::create_dir(&unwritable_dir).unwrap();
std::fs::set_permissions(&unwritable_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let out_path = unwritable_dir.join("output.mp3");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-path").arg(&out_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Output directory is not writable"));
std::fs::set_permissions(&unwritable_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn test_missing_ffmpeg() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Remove ffmpeg from PATH
let orig_path = env::var("PATH").unwrap();
env::set_var("PATH", "");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Required external tool 'ffmpeg' is not installed"));
env::set_var("PATH", orig_path);
}
#[test]
fn test_invalid_voucher_file() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file("not json", ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Failed to parse voucher file"));
}
#[test]
fn test_output_format_selection() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Patch ffprobe to echo valid JSON
let ffprobe = write_temp_file(
"#!/bin/sh\necho '$VALID_FFPROBE'",
".sh"
);
let ffprobe_path = ffprobe.path();
std::fs::set_permissions(ffprobe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let orig_path = env::var("PATH").unwrap();
let new_path = format!("{}:{}", ffprobe_path.parent().unwrap().display(), orig_path);
env::set_var("PATH", new_path);
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-type").arg("flac");
// Should succeed up to ffmpeg (which will fail, but output format logic is exercised)
let assert = cmd.assert();
assert.stderr(predicate::str::contains("ffmpeg"));
// Restore PATH
env::set_var("PATH", orig_path);
}
#[test]
fn test_missing_ffprobe() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Remove ffprobe from PATH
let orig_path = env::var("PATH").unwrap();
env::set_var("PATH", "");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Failed to execute ffprobe"));
env::set_var("PATH", orig_path);
}
#[test]
fn test_voucher_data_validation() {
let aaxc = write_temp_file("", ".aaxc");
// Voucher missing response_groups
let invalid_voucher = r#"{
"content_license": {
"acr": "acr",
"asin": "asin",
"content_metadata": {
"content_reference": {
"acr": "acr",
"asin": "asin",
"codec": "codec",
"content_format": "format",
"content_size_in_bytes": 123,
"file_version": "1",
"marketplace": "market",
"sku": "sku",
"tempo": "tempo",
"version": "v1"
},
"content_url": { "offline_url": "url" },
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"drm_type": "drm",
"granted_right": "right",
"license_id": "id",
"license_response": {
"key": "key",
"iv": "iv",
"rules": [{
"parameters": [{
"expire_date": "date",
"type": "type"
}],
"name": "rule"
}]
},
"license_response_type": "type",
"message": "msg",
"playback_info": {
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"preview": false,
"request_id": "req",
"requires_ad_supported_playback": false,
"status_code": "ok",
"voucher_id": "vid"
},
"response_groups": []
}"#;
let voucher = write_temp_file(invalid_voucher, ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Invalid voucher"));
}
#[test]
fn test_real_conversion_aborted() {
use std::process::{Command as StdCommand, Stdio};
use std::{thread, time};
// Path to real aaxc and voucher files
let aaxc_path = "/home/ondra/Music/Oathbringer_The_Stormlight_Archive_Book_3-AAX_22_64.aaxc";
let voucher_path = "/home/ondra/Music/Oathbringer_The_Stormlight_Archive_Book_3-AAX_22_64.voucher";
// Output file in temp dir
let out_file = tempfile::NamedTempFile::new().unwrap();
let out_path = out_file.path().to_owned();
// Spawn the process
let mut child = StdCommand::new("cargo")
.arg("run")
.arg("--bin")
.arg("audible-util")
.arg("--")
.arg("--aaxc_path").arg(aaxc_path)
.arg("--voucher-path").arg(voucher_path)
.arg("--output-path").arg(&out_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn audible-util");
// Let it run for a short time, then abort
thread::sleep(time::Duration::from_secs(2));
let _ = child.kill();
// Wait for process to exit and collect output
let output = child.wait_with_output().expect("Failed to wait on child");
// Assert process was killed and output contains expected abort/error message
assert!(!output.status.success(), "Process should not succeed when killed early");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Aborted") || stderr.contains("killed") || stderr.contains("signal") || !output.status.success(),
"Expected abort/killed message in stderr, got: {}",
stderr
);
}