#[cfg(test)]
mod tests {
use crate::media::CasStore;
use crate::runtime::builtin::media::color::DominantColorOp;
use crate::runtime::builtin::media::context::MediaToolContext;
use crate::runtime::builtin::media::dimensions::DimensionsOp;
use crate::runtime::builtin::media::import::ImportOp;
use crate::runtime::builtin::media::thumbhash_tool::ThumbhashOp;
use crate::runtime::builtin::media::{MediaOp, MediaOpResult};
use std::sync::Arc;
async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
let dir = tempfile::tempdir().unwrap();
let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())).unwrap());
(dir, ctx)
}
fn fixture_png(w: u32, h: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_pixel(w, h, Rgb([r, g, b]));
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(enc, img.as_raw(), w, h, image::ExtendedColorType::Rgb8)
.unwrap();
buf
}
fn fixture_jpeg(w: u32, h: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_pixel(w, h, Rgb([r, g, b]));
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
buf.into_inner()
}
fn extract_hash(result: MediaOpResult) -> String {
if let MediaOpResult::Metadata(v) = result {
v["hash"].as_str().unwrap().to_string()
} else {
panic!("expected Metadata result");
}
}
#[tokio::test]
async fn pipeline_import_then_dimensions_png() {
let (_dir, ctx) = setup().await;
let png = fixture_png(200, 100, 0, 128, 255);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let import_result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let hash = extract_hash(import_result);
let dims_result = DimensionsOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = dims_result {
assert_eq!(v["width"], 200);
assert_eq!(v["height"], 100);
} else {
panic!("expected Metadata");
}
}
#[tokio::test]
async fn pipeline_import_then_dimensions_jpeg() {
let (_dir, ctx) = setup().await;
let jpeg = fixture_jpeg(300, 150, 255, 128, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &jpeg).unwrap();
let import_result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let hash = extract_hash(import_result);
let dims_result = DimensionsOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = dims_result {
assert_eq!(v["width"], 300);
assert_eq!(v["height"], 150);
}
}
#[tokio::test]
async fn pipeline_import_then_thumbhash() {
let (_dir, ctx) = setup().await;
let png = fixture_png(100, 100, 255, 0, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let import_result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let hash = extract_hash(import_result);
let thumbhash_result = ThumbhashOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = thumbhash_result {
let th = v["thumbhash"].as_str().unwrap();
assert!(!th.is_empty(), "thumbhash should not be empty");
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, th).unwrap();
assert!(
decoded.len() >= 3 && decoded.len() <= 28,
"thumbhash decoded length should be 3-28, got {}",
decoded.len()
);
}
}
#[tokio::test]
async fn pipeline_import_then_dominant_color() {
let (_dir, ctx) = setup().await;
let png = fixture_png(50, 50, 255, 0, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let import_result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let hash = extract_hash(import_result);
let color_result = DominantColorOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = color_result {
let colors = v["colors"].as_array().unwrap();
assert!(
!colors.is_empty(),
"should have at least one dominant color"
);
}
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn pipeline_import_then_thumbnail() {
use crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(500, 400, 0, 255, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let import_result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let hash = extract_hash(import_result);
let thumb_result = ThumbnailOp
.execute(serde_json::json!({"hash": hash, "width": 100}), &ctx)
.await
.unwrap();
if let MediaOpResult::Binary {
data,
mime_type,
metadata,
..
} = thumb_result
{
assert_eq!(mime_type, "image/png");
assert!(!data.is_empty());
assert_eq!(metadata["width"], 100);
assert!(
data.len() < png.len(),
"thumbnail should be smaller than original"
);
} else {
panic!("expected Binary result");
}
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn pipeline_import_thumbnail_dimensions() {
use crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(800, 600, 128, 128, 128);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let import_hash = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let thumb_result = ThumbnailOp
.execute(serde_json::json!({"hash": import_hash, "width": 200}), &ctx)
.await
.unwrap();
let thumb_hash = if let MediaOpResult::Binary { metadata, .. } = &thumb_result {
assert_eq!(metadata["width"], 200);
String::new()
} else {
panic!("expected Binary");
};
let dims = DimensionsOp
.execute(serde_json::json!({"hash": import_hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = dims {
assert_eq!(v["width"], 800);
assert_eq!(v["height"], 600);
}
let _ = thumb_hash; }
#[tokio::test]
async fn import_webp_detected() {
let (_dir, ctx) = setup().await;
let mut webp = Vec::new();
webp.extend_from_slice(b"RIFF");
webp.extend_from_slice(&100u32.to_le_bytes()); webp.extend_from_slice(b"WEBP");
webp.extend_from_slice(&[0u8; 88]);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &webp).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "image/webp");
}
}
#[tokio::test]
async fn import_gif_detected() {
let (_dir, ctx) = setup().await;
let mut gif = Vec::new();
gif.extend_from_slice(b"GIF89a");
gif.extend_from_slice(&[10, 0, 10, 0]); gif.extend_from_slice(&[0u8; 50]);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &gif).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "image/gif");
}
}
#[tokio::test]
async fn import_pdf_detected() {
let (_dir, ctx) = setup().await;
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
pdf.extend_from_slice(&[0u8; 50]);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &pdf).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "application/pdf");
}
}
#[tokio::test]
async fn import_mp3_detected() {
let (_dir, ctx) = setup().await;
let mut mp3 = Vec::new();
mp3.extend_from_slice(b"ID3");
mp3.extend_from_slice(&[4, 0, 0]); mp3.extend_from_slice(&[0u8; 50]);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp3).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
let mime = v["mime_type"].as_str().unwrap();
assert!(
mime == "audio/mpeg" || mime == "audio/mp3",
"expected audio MIME, got: {mime}"
);
}
}
#[tokio::test]
async fn import_mp4_detected() {
let (_dir, ctx) = setup().await;
let mut mp4 = Vec::new();
mp4.extend_from_slice(&[0, 0, 0, 24]); mp4.extend_from_slice(b"ftyp");
mp4.extend_from_slice(b"isom"); mp4.extend_from_slice(&[0, 0, 0, 0]); mp4.extend_from_slice(b"isom"); mp4.extend_from_slice(b"avc1");
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
let mime = v["mime_type"].as_str().unwrap();
assert!(
mime.contains("video") || mime.contains("mp4"),
"expected video MIME, got: {mime}"
);
}
}
#[tokio::test]
async fn import_zip_detected() {
let (_dir, ctx) = setup().await;
let mut zip = Vec::new();
zip.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]);
zip.extend_from_slice(&[0u8; 50]);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &zip).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "application/zip");
}
}
#[tokio::test]
async fn import_charges_budget() {
let (_dir, ctx) = setup().await;
let png = fixture_png(10, 10, 255, 255, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let budget_before = ctx.budget.current_bytes();
assert_eq!(budget_before, 0);
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
let budget_after = ctx.budget.current_bytes();
assert_eq!(
budget_after,
png.len() as u64,
"budget should be charged by file size"
);
}
#[tokio::test]
async fn import_concurrent_same_file() {
let (_dir, ctx) = setup().await;
let png = fixture_png(10, 10, 0, 0, 255);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let path_str = tmp.path().to_string_lossy().to_string();
let futs: Vec<_> = (0..5)
.map(|_| {
let path = path_str.clone();
let ctx = Arc::clone(&ctx);
async move {
ImportOp
.execute(serde_json::json!({"path": path}), &ctx)
.await
}
})
.collect();
let results: Vec<_> = futures::future::join_all(futs).await;
assert!(
results.iter().all(|r| r.is_ok()),
"all concurrent imports should succeed"
);
let hashes: Vec<String> = results
.into_iter()
.map(|r| extract_hash(r.unwrap()))
.collect();
assert!(
hashes.windows(2).all(|w| w[0] == w[1]),
"same file should produce same hash"
);
}
#[tokio::test]
async fn import_large_binary_file() {
let (_dir, ctx) = setup().await;
let data: Vec<u8> = (0..1_000_000u32).map(|i| (i % 256) as u8).collect();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &data).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["size_bytes"], 1_000_000);
assert!(v["hash"].as_str().unwrap().starts_with("blake3:"));
}
}
#[cfg(unix)]
#[tokio::test]
async fn import_follows_symlink() {
use std::os::unix::fs::symlink;
let (_dir, ctx) = setup().await;
let png = fixture_png(20, 20, 0, 128, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let link_dir = tempfile::tempdir().unwrap();
let link_path = link_dir.path().join("link.png");
symlink(tmp.path(), &link_path).unwrap();
let result = ImportOp
.execute(
serde_json::json!({"path": link_path.to_string_lossy()}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "image/png");
assert_eq!(v["size_bytes"], png.len() as u64);
}
}
#[tokio::test]
async fn import_path_with_spaces() {
let (_dir, ctx) = setup().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("my file with spaces.png");
let png = fixture_png(10, 10, 255, 255, 255);
std::fs::write(&path, &png).unwrap();
let result = ImportOp
.execute(serde_json::json!({"path": path.to_string_lossy()}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "image/png");
}
}
#[tokio::test]
async fn import_path_with_unicode() {
let (_dir, ctx) = setup().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("café_résumé_日本語.png");
let png = fixture_png(10, 10, 0, 0, 0);
std::fs::write(&path, &png).unwrap();
let result = ImportOp
.execute(serde_json::json!({"path": path.to_string_lossy()}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = result {
assert_eq!(v["mime_type"], "image/png");
}
}
#[cfg(feature = "media-phash")]
#[tokio::test]
async fn pipeline_import_then_phash() {
use crate::runtime::builtin::media::phash::PhashOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(100, 100, 255, 0, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let hash = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let phash_result = PhashOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = phash_result {
assert!(v["phash"].is_string());
assert_eq!(v["algorithm"], "dct");
}
}
#[cfg(feature = "media-phash")]
#[tokio::test]
async fn pipeline_import_two_images_then_compare() {
use crate::runtime::builtin::media::compare::CompareOp;
let (_dir, ctx) = setup().await;
let img_a = {
use image::{ImageBuffer, Rgb};
let mut img = ImageBuffer::from_pixel(50u32, 50, Rgb([255u8, 0, 0]));
for x in 0..25 {
for y in 0..50 {
img.put_pixel(x, y, Rgb([255, 255, 255]));
}
}
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
enc,
img.as_raw(),
50,
50,
image::ExtendedColorType::Rgb8,
)
.unwrap();
buf
};
let img_b = {
use image::{ImageBuffer, Rgb};
let mut img = ImageBuffer::from_pixel(50u32, 50, Rgb([255u8, 0, 0]));
for x in 25..50 {
for y in 0..50 {
img.put_pixel(x, y, Rgb([0, 128, 255]));
}
}
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
enc,
img.as_raw(),
50,
50,
image::ExtendedColorType::Rgb8,
)
.unwrap();
buf
};
let tmp1 = tempfile::NamedTempFile::new().unwrap();
let tmp2 = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp1.path(), &img_a).unwrap();
std::fs::write(tmp2.path(), &img_b).unwrap();
let h1 = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp1.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let h2 = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp2.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let compare_result = CompareOp
.execute(serde_json::json!({"hash_a": h1, "hash_b": h2}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = compare_result {
let similarity = v["similarity_pct"].as_f64().unwrap();
assert!(
(0.0..=100.0).contains(&similarity),
"similarity should be in 0..100 range, got {similarity}"
);
let distance = v["distance"].as_u64().unwrap();
assert!(
distance > 0,
"visually different images should have non-zero distance"
);
}
}
#[cfg(feature = "media-optimize")]
#[tokio::test]
async fn pipeline_import_then_optimize() {
use crate::runtime::builtin::media::optimize::OptimizeOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(200, 200, 128, 128, 128);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let hash = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let opt_result = OptimizeOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Binary {
data, mime_type, ..
} = opt_result
{
assert_eq!(mime_type, "image/png");
assert!(!data.is_empty());
assert!(
data.len() <= png.len() + 100,
"optimized PNG should not be significantly larger"
);
}
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn pipeline_import_then_convert_png_to_jpeg() {
use crate::runtime::builtin::media::convert::ConvertOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(100, 100, 0, 255, 128);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let hash = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let conv_result = ConvertOp
.execute(serde_json::json!({"hash": hash, "format": "jpeg"}), &ctx)
.await
.unwrap();
if let MediaOpResult::Binary {
data, mime_type, ..
} = conv_result
{
assert_eq!(mime_type, "image/jpeg");
assert_eq!(&data[..3], &[0xFF, 0xD8, 0xFF]);
}
}
#[cfg(feature = "media-metadata")]
#[tokio::test]
async fn pipeline_import_then_metadata() {
use crate::runtime::builtin::media::metadata::MetadataOp;
let (_dir, ctx) = setup().await;
let png = fixture_png(320, 240, 255, 255, 0);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &png).unwrap();
let hash = extract_hash(
ImportOp
.execute(
serde_json::json!({"path": tmp.path().to_string_lossy()}),
&ctx,
)
.await
.unwrap(),
);
let meta_result = MetadataOp
.execute(serde_json::json!({"hash": hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Metadata(v) = meta_result {
assert_eq!(v["width"], 320);
assert_eq!(v["height"], 240);
assert!(v["mime_type"].as_str().unwrap().starts_with("image/"));
}
}
}