diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index fbaef4dd..ca55655e 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,6 +11,8 @@ pub mod password_hasher; pub mod path_resolver_service; pub mod path_service; pub mod thumbnail_service; +#[cfg(test)] +mod thumbnail_service_test; pub mod trash_cleanup_service; pub mod webdav_lock_service; pub mod wopi_discovery_service; diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs new file mode 100644 index 00000000..b9e01eec --- /dev/null +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use super::thumbnail_service::{ThumbnailService, ThumbnailSize}; + +/// Minimal valid 1x1 red PNG (68 bytes). +fn tiny_png() -> Vec { + // Generated from a real 1×1 PNG — smallest valid RGBA image. + let mut img = image::RgbaImage::new(1, 1); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut buf = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageFormat::Png, + ) + .expect("encode test PNG"); + buf +} + +/// Regression test: thumbnail generation must work when the source file lives +/// at a blob-style path (`.blobs/ab/ab1234…`) rather than a logical path +/// (`folder/image.png`). This broke after the blob storage migration +/// (commit 3c7c16f) because the handler passed the logical path — which +/// doesn't exist on disk — to the thumbnail service. +#[tokio::test] +async fn generate_thumbnail_from_blob_path() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let storage_root = tmp.path(); + + // Simulate a blob-store layout: .blobs/ab/.blob + let blob_dir = storage_root.join(".blobs").join("ab"); + std::fs::create_dir_all(&blob_dir).expect("create blob dir"); + let blob_path = blob_dir.join("ab1234567890.blob"); + std::fs::write(&blob_path, tiny_png()).expect("write test blob"); + + let svc = Arc::new(ThumbnailService::new(storage_root, 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + // The key assertion: the service can read from a blob path (not a logical path) + let result = svc + .get_thumbnail("test-file-id", ThumbnailSize::Icon, &blob_path) + .await; + + let thumb_bytes = result.expect("thumbnail generation should succeed from blob path"); + assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty"); + + // Verify it's valid WebP (starts with "RIFF" magic) + assert!( + thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF", + "output should be WebP format" + ); +} + +/// Verify that a non-existent path produces an error, not a panic. +#[tokio::test] +async fn generate_thumbnail_nonexistent_path_returns_error() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let svc = Arc::new(ThumbnailService::new(tmp.path(), 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + let bad_path = tmp.path().join("does-not-exist.png"); + let result = svc + .get_thumbnail("missing-id", ThumbnailSize::Icon, &bad_path) + .await; + + assert!(result.is_err(), "should fail for nonexistent file"); +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 66784b1b..0f523836 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -14,7 +14,7 @@ use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; -use crate::application::ports::storage_ports::StorageUsagePort; +use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::common::di::AppState; use crate::interfaces::errors::AppError; @@ -322,8 +322,20 @@ impl FileHandler { .into_response(); } - let storage_root = state.core.path_service.get_root_path(); - let file_path = storage_root.join(&file.path); + // Resolve the actual blob path on disk (not the logical file path). + let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await { + Ok(h) => h, + Err(err) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File content not found: {}", err) + })), + ) + .into_response(); + } + }; + let file_path = state.core.dedup_service.blob_path(&blob_hash); match thumbnail_service .get_thumbnail(&id, thumb_size.into(), &file_path) @@ -597,12 +609,21 @@ impl FileHandler { .is_supported_image(&file.mime_type) { let file_id = file.id.clone(); - let file_path_rel = file.path.clone(); let thumbnail_service = state.core.thumbnail_service.clone(); - let path_service = state.core.path_service.clone(); + let dedup_service = state.core.dedup_service.clone(); + let file_read = state.repositories.file_read_repository.clone(); tokio::spawn(async move { - let file_path = path_service.get_root_path().join(&file_path_rel); + // Resolve the actual blob path on disk (not the logical file path, + // which doesn't exist when using blob storage). + let blob_hash = match file_read.get_blob_hash(&file_id).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Skipping thumbnails for {}: {}", file_id, e); + return; + } + }; + let file_path = dedup_service.blob_path(&blob_hash); tracing::info!("🖼️ Generating thumbnails for: {}", file_id); thumbnail_service.generate_all_sizes_background(file_id, file_path); });