diff --git a/Cargo.lock b/Cargo.lock index b4fc4f12..c52befe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2928,6 +2928,7 @@ dependencies = [ "serde_yaml", "sha1", "sha2", + "tempfile", "tokio", "tokio-util", "tracing", diff --git a/pmoaudiocache/tests/test_cache.rs b/pmoaudiocache/tests/test_cache.rs new file mode 100644 index 00000000..9e30b36d --- /dev/null +++ b/pmoaudiocache/tests/test_cache.rs @@ -0,0 +1,89 @@ +use pmoaudiocache::cache; +use tempfile::TempDir; + +fn create_test_cache() -> (TempDir, cache::Cache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + (temp_dir, cache) +} + +#[tokio::test] +async fn test_audio_cache_creation() { + let (temp_dir, cache) = create_test_cache(); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +async fn test_add_from_file() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer un fichier FLAC de test (vide pour le moment) + let test_file = tempfile::NamedTempFile::with_suffix(".flac").unwrap(); + // Note: Pour un test complet, il faudrait un vrai fichier FLAC avec métadonnées + // Ici on teste juste l'ajout de fichier basique + std::fs::write(test_file.path(), b"FLAC_DUMMY_DATA").unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + assert!(!pk.is_empty()); +} + +#[tokio::test] +async fn test_audio_config() { + use pmocache::CacheConfig; + + assert_eq!(cache::AudioConfig::file_extension(), "flac"); + assert_eq!(cache::AudioConfig::cache_type(), "flac"); + assert_eq!(cache::AudioConfig::cache_name(), "audio"); + assert_eq!(cache::AudioConfig::default_param(), "orig"); +} + +#[tokio::test] +async fn test_collection_management() { + let (_temp_dir, cache) = create_test_cache(); + + let collection = "test_album"; + + // Ajouter plusieurs pistes à la même collection + for i in 0..3 { + let data = format!("Track {} audio data", i); + let file = tempfile::NamedTempFile::with_suffix(".flac").unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Récupérer la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +async fn test_cache_limit() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 2).unwrap(); + + // Ajouter 3 fichiers (devrait déclencher l'éviction LRU) + for i in 0..3 { + let data = format!("Track {}", i); + let file = tempfile::NamedTempFile::with_suffix(".flac").unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + // Le cache ne devrait contenir que 2 éléments + let count = cache.db.count().unwrap(); + assert_eq!(count, 2); +} diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index 409df8b1..8cf63f3d 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -41,6 +41,9 @@ axum = { version = "0.8", optional = true } pmoconfig = { path = "../pmoconfig", optional = true } serde_yaml = { version = "0.9", optional = true } +[dev-dependencies] +tempfile = "3" + [features] default = [] openapi = ["dep:utoipa"] diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index f4307777..2021e841 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -13,7 +13,7 @@ use serde_json::{Number, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tokio::io::AsyncRead; +use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::sync::RwLock; use tracing; @@ -68,6 +68,88 @@ pub struct Cache { } impl Cache { + /// Vérifie si un fichier est en cache et complet + /// + /// # Returns + /// + /// - `Ok(true)` si le fichier est en cache et complet + /// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime le fichier incomplet) + /// - `Err` en cas d'erreur + async fn check_cached_and_complete(&self, pk: &str) -> Result { + if self.db.get(pk, false).is_ok() { + let file_path = self.get_file_path(pk); + if file_path.exists() { + // Vérifier si le fichier semble complet (taille >= min_prebuffer_size) + if let Ok(metadata) = std::fs::metadata(&file_path) { + let file_size = metadata.len(); + if self.min_prebuffer_size > 0 && file_size < self.min_prebuffer_size { + tracing::warn!( + "File with pk {} in cache is too small ({} bytes < {} bytes), will re-download/re-ingest", + pk, file_size, self.min_prebuffer_size + ); + // Supprimer le fichier incomplet + let _ = std::fs::remove_file(&file_path); + return Ok(false); + } else { + // Déjà en cache et complet + return Ok(true); + } + } + } + } + Ok(false) + } + + /// Vérifie si un download est en cours et attend le prébuffering si nécessaire + /// + /// # Returns + /// + /// - `Ok(Some(pk))` si un download est en cours (et prébuffering terminé) + /// - `Ok(None)` si aucun download en cours + /// - `Err` en cas d'erreur de prébuffering + async fn check_ongoing_download(&self, pk: &str) -> Result> { + let download_handle = { + let downloads = self.downloads.read().await; + downloads.get(pk).cloned() + }; + + if let Some(download) = download_handle { + tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk); + + if self.min_prebuffer_size > 0 { + download.wait_until_min_size(self.min_prebuffer_size).await + .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; + tracing::debug!("Prebuffering complete for pk {}", pk); + } + + return Ok(Some(pk.to_string())); + } + + Ok(None) + } + + /// Finalise l'ajout d'un fichier au cache + /// + /// Cette fonction helper gère le prébuffering et le nettoyage en background + async fn finalize_download(&self, pk: &str, download: Arc) -> Result { + // Attendre le prébuffering (pour le cache progressif) + if self.min_prebuffer_size > 0 { + download.wait_until_min_size(self.min_prebuffer_size).await + .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; + tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size); + } + + // Lancer une tâche de nettoyage en background + let downloads_clone = self.downloads.clone(); + let pk_clone = pk.to_string(); + tokio::spawn(async move { + let _ = download.wait_until_finished().await; + downloads_clone.write().await.remove(&pk_clone); + }); + + Ok(pk.to_string()) + } + /// Crée un nouveau cache sans transformer /// /// # Arguments @@ -201,46 +283,14 @@ impl Cache { tracing::debug!("Computed pk {} for URL {}", pk, url); // 3. Vérifier si le fichier est déjà en cache ET complet - if self.db.get(&pk, false).is_ok() { - let file_path = self.get_file_path(&pk); - if file_path.exists() { - // Vérifier si le fichier semble complet (taille >= min_prebuffer_size) - if let Ok(metadata) = std::fs::metadata(&file_path) { - let file_size = metadata.len(); - if self.min_prebuffer_size > 0 && file_size < self.min_prebuffer_size { - tracing::warn!( - "File with pk {} in cache is too small ({} bytes < {} bytes), will re-download/re-ingest", - pk, file_size, self.min_prebuffer_size - ); - // Supprimer le fichier incomplet - let _ = std::fs::remove_file(&file_path); - // Continuer avec le téléchargement/ingestion - } else { - // Déjà en cache et complet, update timestamp et retour rapide - tracing::debug!("File with pk {} already in cache, updating timestamp", pk); - self.db.update_hit(&pk)?; - return Ok(pk); - } - } - } + if self.check_cached_and_complete(&pk).await? { + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; + return Ok(pk); } // 4. Vérifier si un download est déjà en cours pour ce pk - let download_handle = { - let downloads = self.downloads.read().await; - downloads.get(&pk).cloned() - }; - - if let Some(download) = download_handle { - // Download déjà en cours pour ce contenu, attendre le prébuffering - tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk); - - if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await - .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; - tracing::debug!("Prebuffering complete for pk {}", pk); - } - + if let Some(pk) = self.check_ongoing_download(&pk).await? { return Ok(pk); } @@ -259,27 +309,14 @@ impl Cache { // Ajouter immédiatement à la DB self.db.add(&pk, None, collection)?; self.db.set_origin_url(&pk, url)?; + // Appliquer la politique d'éviction LRU si nécessaire if let Err(e) = self.enforce_limit().await { tracing::warn!("Error enforcing cache limit: {}", e); } - // Attendre le prébuffering (pour le cache progressif) - if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await - .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; - tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size); - } - - // Lancer une tâche de nettoyage en background - let downloads_clone = self.downloads.clone(); - let pk_clone = pk.clone(); - tokio::spawn(async move { - let _ = download.wait_until_finished().await; - downloads_clone.write().await.remove(&pk_clone); - }); - - Ok(pk) + // Finaliser avec prébuffering et nettoyage + self.finalize_download(&pk, download).await } /// Ajoute un fichier à partir d'un flux asynchrone. @@ -330,53 +367,19 @@ impl Cache { } // 3. Vérifier si le fichier est déjà en cache ET complet - if self.db.get(&pk, false).is_ok() { - let file_path = self.get_file_path(&pk); - if file_path.exists() { - // Vérifier si le fichier semble complet (taille >= min_prebuffer_size) - if let Ok(metadata) = std::fs::metadata(&file_path) { - let file_size = metadata.len(); - if self.min_prebuffer_size > 0 && file_size < self.min_prebuffer_size { - tracing::warn!( - "File with pk {} in cache is too small ({} bytes < {} bytes), will re-download/re-ingest", - pk, file_size, self.min_prebuffer_size - ); - // Supprimer le fichier incomplet - let _ = std::fs::remove_file(&file_path); - // Continuer avec le téléchargement/ingestion - } else { - // Déjà en cache et complet, update timestamp et retour rapide - tracing::debug!("File with pk {} already in cache, updating timestamp", pk); - self.db.update_hit(&pk)?; - return Ok(pk); - } - } - } + if self.check_cached_and_complete(&pk).await? { + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; + return Ok(pk); } // 4. Vérifier si un download est déjà en cours pour ce pk - let download_handle = { - let downloads = self.downloads.read().await; - downloads.get(&pk).cloned() - }; - - if let Some(download) = download_handle { - // Download déjà en cours pour ce contenu, attendre le prébuffering - tracing::debug!("Download already in progress for pk {}, waiting for prebuffering", pk); - - if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await - .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; - tracing::debug!("Prebuffering complete for pk {}", pk); - } - + if let Some(pk) = self.check_ongoing_download(&pk).await? { return Ok(pk); } // 5. Reconstituer le reader complet (header + reste) - // Utiliser tokio::io::chain pour créer un reader composé use std::io::Cursor; - use tokio::io::AsyncReadExt; let header_reader = Cursor::new(header); let full_reader = header_reader.chain(reader); @@ -400,22 +403,8 @@ impl Cache { tracing::warn!("Error enforcing cache limit: {}", e); } - // Attendre le prébuffering (pour le cache progressif) - if self.min_prebuffer_size > 0 { - download.wait_until_min_size(self.min_prebuffer_size).await - .map_err(|e| anyhow!("Prebuffering failed: {}", e))?; - tracing::debug!("Prebuffering complete for pk {} ({} bytes)", pk, self.min_prebuffer_size); - } - - // Lancer une tâche de nettoyage en background - let downloads_clone = self.downloads.clone(); - let pk_clone = pk.clone(); - tokio::spawn(async move { - let _ = download.wait_until_finished().await; - downloads_clone.write().await.remove(&pk_clone); - }); - - Ok(pk) + // Finaliser avec prébuffering et nettoyage + self.finalize_download(&pk, download).await } /// Ajoute un fichier local au cache @@ -866,17 +855,10 @@ impl Cache { let mut removed = 0; for entry in old_entries { - // Supprimer tous les fichiers avec ce pk (toutes variantes) - if let Ok(mut dir_entries) = tokio::fs::read_dir(&self.dir).await { - while let Ok(Some(dir_entry)) = dir_entries.next_entry().await { - if let Some(filename) = dir_entry.file_name().to_str() { - // Format: {pk}.{param}.{ext} - if filename.starts_with(&entry.pk) - && filename.starts_with(&format!("{}.", entry.pk)) - { - let _ = tokio::fs::remove_file(dir_entry.path()).await; - } - } + // Utiliser get_file_paths() pour obtenir tous les fichiers de cette entrée + if let Ok(paths) = self.get_file_paths(&entry.pk) { + for path in paths { + let _ = tokio::fs::remove_file(path).await; } } diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index 657a11ae..e73ca2a5 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -678,7 +678,7 @@ impl DB { let conn = self.lock_conn("get_oldest"); let mut stmt = conn.prepare( - "SELECT pk, source_url, collection, hits, last_used, metadata_json + "SELECT pk, id, collection, hits, last_used FROM asset ORDER BY last_used ASC, hits ASC LIMIT ?1", diff --git a/pmocache/tests/test_cache.rs b/pmocache/tests/test_cache.rs new file mode 100644 index 00000000..f8aa2e69 --- /dev/null +++ b/pmocache/tests/test_cache.rs @@ -0,0 +1,362 @@ +use pmocache::{Cache, CacheConfig}; +use std::io::Write; +use tempfile::TempDir; + +/// Configuration de test simple +struct TestConfig; + +impl CacheConfig for TestConfig { + fn file_extension() -> &'static str { + "dat" + } + + fn cache_type() -> &'static str { + "test" + } + + fn cache_name() -> &'static str { + "testcache" + } +} + +type TestCache = Cache; + +fn create_test_cache(limit: usize) -> (TempDir, TestCache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = TestCache::new(temp_dir.path().to_str().unwrap(), limit).unwrap(); + (temp_dir, cache) +} + +#[tokio::test] +async fn test_cache_creation() { + let (temp_dir, cache) = create_test_cache(10); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +async fn test_add_from_file() { + let (_temp_dir, cache) = create_test_cache(10); + + // Créer un fichier temporaire pour le test + let test_file = tempfile::NamedTempFile::new().unwrap(); + let test_data = b"Hello, World! This is test data."; + std::fs::write(test_file.path(), test_data).unwrap(); + + // Ajouter le fichier au cache + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Vérifier que le fichier est dans le cache + assert!(!pk.is_empty()); + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + + // Vérifier le contenu + let cached_data = std::fs::read(&cached_path).unwrap(); + assert_eq!(&cached_data, test_data); +} + +#[tokio::test] +async fn test_add_from_reader() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data from reader"; + let reader = std::io::Cursor::new(test_data.to_vec()); + + // Ajouter depuis un reader + let pk = cache + .add_from_reader(None, reader, Some(test_data.len() as u64), None) + .await + .unwrap(); + + // Attendre que le téléchargement soit terminé + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier que le fichier est dans le cache + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + + // Vérifier le contenu + let cached_data = std::fs::read(&cached_path).unwrap(); + assert_eq!(&cached_data, test_data); +} + +#[tokio::test] +async fn test_cache_deduplication() { + let (_temp_dir, cache) = create_test_cache(10); + + // Créer deux fichiers avec le même contenu + let test_data = b"Same content for both files"; + + let file1 = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file1.path(), test_data).unwrap(); + + let file2 = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file2.path(), test_data).unwrap(); + + // Ajouter les deux fichiers + let pk1 = cache + .add_from_file(file1.path().to_str().unwrap(), None) + .await + .unwrap(); + + let pk2 = cache + .add_from_file(file2.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Les deux devraient avoir le même pk (déduplication) + assert_eq!(pk1, pk2); + + // Il ne devrait y avoir qu'une seule entrée en DB + assert_eq!(cache.db.count().unwrap(), 1); +} + +#[tokio::test] +async fn test_cache_collection() { + let (_temp_dir, cache) = create_test_cache(10); + + let collection = "test_album"; + + // Ajouter plusieurs fichiers à la même collection + let mut pks = Vec::new(); + for i in 0..3 { + let data = format!("Track {} data", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + pks.push(pk); + } + + // Récupérer tous les fichiers de la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +async fn test_delete_item() { + let (_temp_dir, cache) = create_test_cache(10); + + // Ajouter un fichier + let test_data = b"Data to be deleted"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Vérifier qu'il existe + assert!(cache.get(&pk).await.is_ok()); + + // Supprimer + cache.delete_item(&pk).await.unwrap(); + + // Vérifier qu'il n'existe plus + assert!(cache.get(&pk).await.is_err()); +} + +#[tokio::test] +async fn test_delete_collection() { + let (_temp_dir, cache) = create_test_cache(10); + + let collection = "test_collection_delete"; + + // Ajouter plusieurs fichiers + for i in 0..3 { + let data = format!("Item {}", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Vérifier que la collection existe + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); + + // Supprimer la collection + cache.delete_collection(collection).await.unwrap(); + + // Vérifier que la collection est vide + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 0); +} + +#[tokio::test] +async fn test_lru_eviction() { + // Créer un cache avec une limite de 3 éléments + let (_temp_dir, cache) = create_test_cache(3); + + let mut pks = Vec::new(); + + // Ajouter 5 fichiers (devrait déclencher l'éviction) + for i in 0..5 { + let data = format!("File {} data", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + pks.push(pk); + + // Petit délai pour s'assurer que les timestamps sont différents + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + // Le cache ne devrait contenir que 3 éléments (les plus récents) + let count = cache.db.count().unwrap(); + assert_eq!(count, 3); + + // Les 2 premiers fichiers devraient avoir été évincés + assert!(cache.get(&pks[0]).await.is_err()); + assert!(cache.get(&pks[1]).await.is_err()); + + // Les 3 derniers devraient être présents + assert!(cache.get(&pks[2]).await.is_ok()); + assert!(cache.get(&pks[3]).await.is_ok()); + assert!(cache.get(&pks[4]).await.is_ok()); +} + +#[tokio::test] +async fn test_cache_purge() { + let (_temp_dir, cache) = create_test_cache(10); + + // Ajouter plusieurs fichiers + for i in 0..3 { + let data = format!("File {}", i); + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), data.as_bytes()).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + } + + assert_eq!(cache.db.count().unwrap(), 3); + + // Purger le cache + cache.purge().await.unwrap(); + + // Le cache devrait être vide + assert_eq!(cache.db.count().unwrap(), 0); +} + +#[tokio::test] +async fn test_get_metadata() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Ajouter des métadonnées + cache + .db + .set_a_metadata(&pk, "test_key", serde_json::json!("test_value")) + .unwrap(); + + // Récupérer les métadonnées + let value = cache.get_a_metadata(&pk, "test_key").await.unwrap(); + assert_eq!(value, Some(serde_json::json!("test_value"))); +} + +#[tokio::test] +async fn test_touch() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + let entry_before = cache.db.get(&pk, false).unwrap(); + let hits_before = entry_before.hits; + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Touch le fichier + cache.touch(&pk).await.unwrap(); + + let entry_after = cache.db.get(&pk, false).unwrap(); + assert_eq!(entry_after.hits, hits_before + 1); +} + +#[tokio::test] +async fn test_consolidate() { + let (temp_dir, cache) = create_test_cache(10); + + // Ajouter un fichier + let test_data = b"Test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Supprimer manuellement le fichier (créer un orphelin) + let file_path = cache.get_file_path(&pk); + std::fs::remove_file(&file_path).unwrap(); + + // Consolider devrait supprimer l'entrée orpheline de la DB + cache.consolidate().await.unwrap(); + + // L'entrée ne devrait plus exister en DB + assert!(cache.db.get(&pk, false).is_err()); +} + +#[tokio::test] +async fn test_prebuffer_size() { + let (_temp_dir, mut cache) = create_test_cache(10); + + // Configurer la taille de prébuffering + let prebuffer_size = 1024; + cache.set_prebuffer_size(prebuffer_size); + + assert_eq!(cache.get_prebuffer_size(), prebuffer_size); +} + +#[tokio::test] +async fn test_is_finished() { + let (_temp_dir, cache) = create_test_cache(10); + + let test_data = b"Small test data"; + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), test_data).unwrap(); + + let pk = cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Attendre que le téléchargement soit terminé + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier qu'il est bien terminé + assert!(cache.is_finished(&pk).await); +} diff --git a/pmocache/tests/test_db.rs b/pmocache/tests/test_db.rs new file mode 100644 index 00000000..7181beca --- /dev/null +++ b/pmocache/tests/test_db.rs @@ -0,0 +1,309 @@ +use pmocache::db::{CacheEntry, DB}; +use serde_json::{json, Value}; +use std::path::Path; +use tempfile::TempDir; + +/// Crée une DB temporaire pour les tests +fn create_test_db() -> (TempDir, DB) { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DB::init(&db_path).unwrap(); + (temp_dir, db) +} + +#[test] +fn test_db_init() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db = DB::init(&db_path); + assert!(db.is_ok()); + assert!(db_path.exists()); +} + +#[test] +fn test_add_and_get() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_123"; + let id = Some("test_id"); + let collection = Some("test_collection"); + + // Ajouter une entrée + let result = db.add(pk, id, collection); + assert!(result.is_ok()); + + // Récupérer l'entrée + let entry = db.get(pk, false); + assert!(entry.is_ok()); + + let entry = entry.unwrap(); + assert_eq!(entry.pk, pk); + assert_eq!(entry.id.as_deref(), id); + assert_eq!(entry.collection.as_deref(), collection); + assert_eq!(entry.hits, 0); +} + +#[test] +fn test_add_with_metadata() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_456"; + let metadata = json!({ + "title": "Test Track", + "artist": "Test Artist", + "duration": 180, + "bitrate": 320 + }); + + // Ajouter avec métadonnées + let result = db.add_with_metadata(pk, None, None, Some(&metadata)); + assert!(result.is_ok()); + + // Récupérer l'entrée avec métadonnées + let entry = db.get(pk, true).unwrap(); + assert_eq!(entry.pk, pk); + assert!(entry.metadata.is_some()); + + let stored_metadata = entry.metadata.unwrap(); + assert_eq!(stored_metadata["title"], "Test Track"); + assert_eq!(stored_metadata["artist"], "Test Artist"); + assert_eq!(stored_metadata["duration"], 180); + assert_eq!(stored_metadata["bitrate"], 320); +} + +#[test] +fn test_update_hit() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_789"; + db.add(pk, None, None).unwrap(); + + // Récupérer l'entrée initiale + let entry = db.get(pk, false).unwrap(); + let initial_hits = entry.hits; + let initial_last_used = entry.last_used.clone(); + + // Attendre un peu pour que le timestamp change + std::thread::sleep(std::time::Duration::from_millis(10)); + + // Mettre à jour le hit + db.update_hit(pk).unwrap(); + + // Vérifier que hits a augmenté et last_used a changé + let entry = db.get(pk, false).unwrap(); + assert_eq!(entry.hits, initial_hits + 1); + assert_ne!(entry.last_used, initial_last_used); +} + +#[test] +fn test_delete() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_delete"; + db.add(pk, None, None).unwrap(); + + // Vérifier que l'entrée existe + assert!(db.get(pk, false).is_ok()); + + // Supprimer l'entrée + let result = db.delete(pk); + assert!(result.is_ok()); + + // Vérifier que l'entrée n'existe plus + assert!(db.get(pk, false).is_err()); +} + +#[test] +fn test_get_by_collection() { + let (_temp_dir, db) = create_test_db(); + + let collection = "test_collection"; + + // Ajouter plusieurs entrées dans la même collection + db.add("pk1", None, Some(collection)).unwrap(); + db.add("pk2", None, Some(collection)).unwrap(); + db.add("pk3", None, Some("other_collection")).unwrap(); + + // Récupérer les entrées de la collection + let entries = db.get_by_collection(collection, false).unwrap(); + + assert_eq!(entries.len(), 2); + assert!(entries.iter().any(|e| e.pk == "pk1")); + assert!(entries.iter().any(|e| e.pk == "pk2")); + assert!(!entries.iter().any(|e| e.pk == "pk3")); +} + +#[test] +fn test_delete_collection() { + let (_temp_dir, db) = create_test_db(); + + let collection = "test_collection_to_delete"; + + db.add("pk1", None, Some(collection)).unwrap(); + db.add("pk2", None, Some(collection)).unwrap(); + db.add("pk3", None, Some("other_collection")).unwrap(); + + // Supprimer la collection + let result = db.delete_collection(collection); + assert!(result.is_ok()); + + // Vérifier que les entrées de la collection sont supprimées + let entries = db.get_by_collection(collection, false).unwrap(); + assert_eq!(entries.len(), 0); + + // Vérifier que l'autre collection existe toujours + assert!(db.get("pk3", false).is_ok()); +} + +#[test] +fn test_get_oldest() { + let (_temp_dir, db) = create_test_db(); + + // Ajouter plusieurs entrées avec des timestamps différents + db.add("pk1", None, None).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + db.add("pk2", None, None).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + db.add("pk3", None, None).unwrap(); + + // Mettre à jour le hit de pk1 pour le rendre plus récent + std::thread::sleep(std::time::Duration::from_millis(10)); + db.update_hit("pk1").unwrap(); + + // Récupérer les 2 plus anciennes entrées + let oldest = db.get_oldest(2).unwrap(); + + assert_eq!(oldest.len(), 2); + // pk2 et pk3 devraient être les plus anciennes + assert!(oldest.iter().any(|e| e.pk == "pk2")); + assert!(oldest.iter().any(|e| e.pk == "pk3")); +} + +#[test] +fn test_count() { + let (_temp_dir, db) = create_test_db(); + + assert_eq!(db.count().unwrap(), 0); + + db.add("pk1", None, None).unwrap(); + assert_eq!(db.count().unwrap(), 1); + + db.add("pk2", None, None).unwrap(); + assert_eq!(db.count().unwrap(), 2); + + db.delete("pk1").unwrap(); + assert_eq!(db.count().unwrap(), 1); +} + +#[test] +fn test_purge() { + let (_temp_dir, db) = create_test_db(); + + db.add("pk1", None, None).unwrap(); + db.add("pk2", None, None).unwrap(); + db.add("pk3", None, None).unwrap(); + + assert_eq!(db.count().unwrap(), 3); + + // Purger toutes les entrées + let result = db.purge(); + assert!(result.is_ok()); + + assert_eq!(db.count().unwrap(), 0); +} + +#[test] +fn test_origin_url() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_url"; + let url = "https://example.com/test.flac"; + + db.add(pk, None, None).unwrap(); + db.set_origin_url(pk, url).unwrap(); + + let retrieved_url = db.get_origin_url(pk).unwrap(); + assert_eq!(retrieved_url, Some(url.to_string())); +} + +#[test] +fn test_get_from_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_by_id"; + let collection = "my_collection"; + let id = "my_unique_id"; + + db.add(pk, Some(id), Some(collection)).unwrap(); + + // Récupérer par (collection, id) + let entry = db.get_from_id(collection, id, false).unwrap(); + assert_eq!(entry.pk, pk); + assert_eq!(entry.id.as_deref(), Some(id)); + assert_eq!(entry.collection.as_deref(), Some(collection)); +} + +#[test] +fn test_does_collection_contain_id() { + let (_temp_dir, db) = create_test_db(); + + let collection = "my_collection"; + let id = "my_id"; + + assert!(!db.does_collection_contain_id(collection, id)); + + db.add("pk", Some(id), Some(collection)).unwrap(); + + assert!(db.does_collection_contain_id(collection, id)); +} + +#[test] +fn test_get_pk_from_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_123"; + let collection = "my_collection"; + let id = "my_id"; + + db.add(pk, Some(id), Some(collection)).unwrap(); + + let retrieved_pk = db.get_pk_from_id(collection, id).unwrap(); + assert_eq!(retrieved_pk, pk); +} + +#[test] +fn test_set_id() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk"; + db.add(pk, None, None).unwrap(); + + // Définir l'id + let new_id = "new_id"; + db.set_id(pk, new_id).unwrap(); + + let entry = db.get(pk, false).unwrap(); + assert_eq!(entry.id.as_deref(), Some(new_id)); +} + +#[test] +fn test_metadata_types() { + let (_temp_dir, db) = create_test_db(); + + let pk = "test_pk_types"; + db.add(pk, None, None).unwrap(); + + // Tester les différents types de métadonnées + db.set_a_metadata(pk, "string_val", Value::String("test".to_string())).unwrap(); + db.set_a_metadata(pk, "number_val", json!(42)).unwrap(); + db.set_a_metadata(pk, "bool_val", Value::Bool(true)).unwrap(); + db.set_a_metadata(pk, "null_val", Value::Null).unwrap(); + + // Vérifier les valeurs + assert_eq!(db.get_metadata_value(pk, "string_val").unwrap(), Some(Value::String("test".to_string()))); + assert_eq!(db.get_metadata_value(pk, "number_val").unwrap(), Some(json!(42))); + assert_eq!(db.get_metadata_value(pk, "bool_val").unwrap(), Some(Value::Bool(true))); + assert_eq!(db.get_metadata_value(pk, "null_val").unwrap(), Some(Value::Null)); +} diff --git a/pmocovers/tests/test_cache.rs b/pmocovers/tests/test_cache.rs new file mode 100644 index 00000000..6b53dc63 --- /dev/null +++ b/pmocovers/tests/test_cache.rs @@ -0,0 +1,144 @@ +use pmocovers::cache; +use tempfile::TempDir; +use image::{ImageBuffer, Rgba}; + +fn create_test_cache() -> (TempDir, cache::Cache) { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + (temp_dir, cache) +} + +/// Crée une image de test simple +fn create_test_image(width: u32, height: u32) -> Vec { + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(width, height, |x, y| { + if (x + y) % 2 == 0 { + Rgba([255, 0, 0, 255]) // Rouge + } else { + Rgba([0, 0, 255, 255]) // Bleu + } + }); + + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + buffer +} + +#[tokio::test] +async fn test_cover_cache_creation() { + let (temp_dir, cache) = create_test_cache(); + assert_eq!(cache.cache_dir(), temp_dir.path()); +} + +#[tokio::test] +async fn test_add_image_from_file() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer une image de test + let test_image = create_test_image(100, 100); + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &test_image).unwrap(); + + // Ajouter au cache + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + assert!(!pk.is_empty()); + + // Attendre la fin de la conversion + cache.wait_until_finished(&pk).await.unwrap(); + + // Vérifier que le fichier WebP existe + let cached_path = cache.get(&pk).await.unwrap(); + assert!(cached_path.exists()); + assert!(cached_path.extension().unwrap() == "webp"); +} + +#[tokio::test] +async fn test_covers_config() { + use pmocache::CacheConfig; + + assert_eq!(cache::CoversConfig::file_extension(), "webp"); + assert_eq!(cache::CoversConfig::cache_type(), "image"); + assert_eq!(cache::CoversConfig::cache_name(), "covers"); +} + +#[tokio::test] +async fn test_collection_management() { + let (_temp_dir, cache) = create_test_cache(); + + let collection = "album_covers"; + + // Ajouter plusieurs images à la même collection + for i in 0..3 { + let img = create_test_image(50 + i * 10, 50 + i * 10); + let file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file.path(), &img).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), Some(collection)) + .await + .unwrap(); + } + + // Récupérer la collection + let collection_files = cache.get_collection(collection).await.unwrap(); + assert_eq!(collection_files.len(), 3); +} + +#[tokio::test] +async fn test_cache_limit() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 2).unwrap(); + + // Ajouter 3 images (devrait déclencher l'éviction LRU) + for i in 0..3 { + let img = create_test_image(100, 100); + let file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file.path(), &img).unwrap(); + + cache + .add_from_file(file.path().to_str().unwrap(), None) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + + // Le cache ne devrait contenir que 2 éléments + let count = cache.db.count().unwrap(); + assert_eq!(count, 2); +} + +#[tokio::test] +async fn test_deduplication() { + let (_temp_dir, cache) = create_test_cache(); + + // Créer deux fichiers avec la même image + let img = create_test_image(100, 100); + + let file1 = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file1.path(), &img).unwrap(); + + let file2 = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(file2.path(), &img).unwrap(); + + // Ajouter les deux images + let pk1 = cache + .add_from_file(file1.path().to_str().unwrap(), None) + .await + .unwrap(); + + let pk2 = cache + .add_from_file(file2.path().to_str().unwrap(), None) + .await + .unwrap(); + + // Les deux devraient avoir le même pk (déduplication) + assert_eq!(pk1, pk2); + + // Il ne devrait y avoir qu'une seule entrée en DB + assert_eq!(cache.db.count().unwrap(), 1); +} diff --git a/pmocovers/tests/test_webp.rs b/pmocovers/tests/test_webp.rs new file mode 100644 index 00000000..8bb7b1a2 --- /dev/null +++ b/pmocovers/tests/test_webp.rs @@ -0,0 +1,158 @@ +use image::{DynamicImage, ImageBuffer, Rgba}; +use pmocovers::webp::{encode_webp, ensure_square}; + +/// Crée une image de test simple +fn create_test_image(width: u32, height: u32) -> DynamicImage { + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(width, height, |x, y| { + if (x + y) % 2 == 0 { + Rgba([255, 0, 0, 255]) + } else { + Rgba([0, 0, 255, 255]) + } + }); + DynamicImage::ImageRgba8(img) +} + +#[test] +fn test_encode_webp() { + let img = create_test_image(100, 100); + let webp_data = encode_webp(&img); + + assert!(webp_data.is_ok()); + let data = webp_data.unwrap(); + assert!(!data.is_empty()); + + // Vérifier la signature WebP (RIFF...WEBP) + assert_eq!(&data[0..4], b"RIFF"); + assert_eq!(&data[8..12], b"WEBP"); +} + +#[test] +fn test_ensure_square_portrait() { + // Image portrait (plus haute que large) + let img = create_test_image(100, 200); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_landscape() { + // Image landscape (plus large que haute) + let img = create_test_image(200, 100); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_already_square() { + // Image déjà carrée + let img = create_test_image(150, 150); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_small_image() { + // Petite image qui doit être agrandie + let img = create_test_image(50, 50); + let square = ensure_square(&img, 256); + + assert_eq!(square.width(), 256); + assert_eq!(square.height(), 256); +} + +#[test] +fn test_ensure_square_different_sizes() { + let img = create_test_image(100, 100); + + // Tester différentes tailles de sortie + for size in [64, 128, 256, 512] { + let square = ensure_square(&img, size); + assert_eq!(square.width(), size); + assert_eq!(square.height(), size); + } +} + +#[tokio::test] +async fn test_generate_variant() { + use pmocovers::cache; + use tempfile::TempDir; + + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + + // Créer et ajouter une image + let img = create_test_image(400, 400); + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &buffer).unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + cache.wait_until_finished(&pk).await.unwrap(); + + // Générer une variante de taille 128 + let variant_data = pmocovers::webp::generate_variant(&cache, &pk, 128) + .await + .unwrap(); + + assert!(!variant_data.is_empty()); + + // Vérifier que c'est bien du WebP + assert_eq!(&variant_data[0..4], b"RIFF"); + assert_eq!(&variant_data[8..12], b"WEBP"); + + // Vérifier que le fichier de la variante a été créé + let variant_path = cache.get_file_path_with_qualifier(&pk, "128"); + assert!(variant_path.exists()); +} + +#[tokio::test] +async fn test_generate_variant_caching() { + use pmocovers::cache; + use tempfile::TempDir; + + let temp_dir = tempfile::tempdir().unwrap(); + let cache = cache::new_cache(temp_dir.path().to_str().unwrap(), 10).unwrap(); + + // Créer et ajouter une image + let img = create_test_image(400, 400); + let mut buffer = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageFormat::Png) + .unwrap(); + + let test_file = tempfile::NamedTempFile::with_suffix(".png").unwrap(); + std::fs::write(test_file.path(), &buffer).unwrap(); + + let pk = cache + .add_from_file(test_file.path().to_str().unwrap(), None) + .await + .unwrap(); + + cache.wait_until_finished(&pk).await.unwrap(); + + // Générer la variante une première fois + let variant1 = pmocovers::webp::generate_variant(&cache, &pk, 256) + .await + .unwrap(); + + // Générer la variante une deuxième fois (devrait lire depuis le cache) + let variant2 = pmocovers::webp::generate_variant(&cache, &pk, 256) + .await + .unwrap(); + + // Les deux devraient être identiques + assert_eq!(variant1, variant2); +}