//! L5 tests for the embedder-configuration use cases (LOT C2). //! //! Ports are faked in-memory so the use cases run without any I/O: //! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a //! `Vec` with the same upsert/delete-NotFound semantics as the real //! `FsEmbedderProfileStore`, //! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant //! [`EmbedderEnvReport`] (best-effort port, never errors). use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError}; use domain::profile::{EmbedderProfile, EmbedderStrategy}; use application::{ DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- /// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics: /// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`]. #[derive(Default, Clone)] struct InMemoryEmbedderProfileStore(Arc>>); #[async_trait] impl EmbedderProfileStore for InMemoryEmbedderProfileStore { async fn list(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { let mut v = self.0.lock().unwrap(); if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) { *slot = profile.clone(); } else { v.push(profile.clone()); } Ok(()) } async fn delete(&self, id: &str) -> Result<(), StoreError> { let mut v = self.0.lock().unwrap(); let before = v.len(); v.retain(|p| p.id != id); if v.len() == before { return Err(StoreError::NotFound); } Ok(()) } } /// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible). struct FixedEnvInspector(EmbedderEnvReport); #[async_trait] impl EmbedderEnvInspector for FixedEnvInspector { async fn inspect(&self) -> EmbedderEnvReport { self.0.clone() } } fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput { SaveEmbedderProfileInput { id: id.to_owned(), name: name.to_owned(), strategy: EmbedderStrategy::LocalOnnx, model: Some("multilingual-e5-small".to_owned()), endpoint: None, api_key_env: None, dimension, } } // --------------------------------------------------------------------------- // ListEmbedderProfiles / SaveEmbedderProfile // --------------------------------------------------------------------------- #[tokio::test] async fn list_is_empty_then_contains_saved_profile() { let store = InMemoryEmbedderProfileStore::default(); let list = ListEmbedderProfiles::new(Arc::new(store.clone())); let save = SaveEmbedderProfile::new(Arc::new(store.clone())); assert!( list.execute().await.unwrap().profiles.is_empty(), "no profile configured ⇒ empty list (default `none` posture)" ); let saved = save .execute(save_input("local-onnx", "Local ONNX", 384)) .await .unwrap(); assert_eq!(saved.profile.id, "local-onnx"); assert_eq!(saved.profile.dimension, 384); let listed = list.execute().await.unwrap().profiles; assert_eq!(listed.len(), 1); assert_eq!(listed[0], saved.profile); } #[tokio::test] async fn save_upserts_by_id_without_duplicating() { let store = InMemoryEmbedderProfileStore::default(); let save = SaveEmbedderProfile::new(Arc::new(store.clone())); let list = ListEmbedderProfiles::new(Arc::new(store.clone())); save.execute(save_input("e", "before", 384)).await.unwrap(); let updated = save.execute(save_input("e", "after", 768)).await.unwrap(); let listed = list.execute().await.unwrap().profiles; assert_eq!(listed.len(), 1, "same id replaces, no duplicate"); assert_eq!(listed[0], updated.profile); assert_eq!(listed[0].name, "after"); assert_eq!(listed[0].dimension, 768); } #[tokio::test] async fn save_invalid_dimension_is_invalid_and_writes_nothing() { let store = InMemoryEmbedderProfileStore::default(); let save = SaveEmbedderProfile::new(Arc::new(store.clone())); let list = ListEmbedderProfiles::new(Arc::new(store.clone())); let err = save .execute(save_input("bad", "Bad", 0)) .await .expect_err("dimension 0 must be rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); assert!( list.execute().await.unwrap().profiles.is_empty(), "a rejected profile must not be persisted" ); } #[tokio::test] async fn save_empty_name_is_invalid_and_writes_nothing() { let store = InMemoryEmbedderProfileStore::default(); let save = SaveEmbedderProfile::new(Arc::new(store.clone())); let list = ListEmbedderProfiles::new(Arc::new(store.clone())); let err = save .execute(save_input("id", "", 384)) .await .expect_err("empty name must be rejected"); assert_eq!(err.code(), "INVALID", "got {err:?}"); assert!( list.execute().await.unwrap().profiles.is_empty(), "a rejected profile must not be persisted" ); } // --------------------------------------------------------------------------- // DeleteEmbedderProfile // --------------------------------------------------------------------------- #[tokio::test] async fn delete_removes_existing_profile() { let store = InMemoryEmbedderProfileStore::default(); let save = SaveEmbedderProfile::new(Arc::new(store.clone())); let delete = DeleteEmbedderProfile::new(Arc::new(store.clone())); let list = ListEmbedderProfiles::new(Arc::new(store.clone())); save.execute(save_input("a", "A", 384)).await.unwrap(); save.execute(save_input("b", "B", 384)).await.unwrap(); delete .execute(DeleteEmbedderProfileInput { id: "a".to_owned() }) .await .unwrap(); let listed = list.execute().await.unwrap().profiles; assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, "b"); } #[tokio::test] async fn delete_unknown_id_is_not_found() { let store = InMemoryEmbedderProfileStore::default(); let delete = DeleteEmbedderProfile::new(Arc::new(store)); let err = delete .execute(DeleteEmbedderProfileInput { id: "ghost".to_owned(), }) .await .expect_err("deleting an unknown id errors"); assert_eq!( err.code(), "NOT_FOUND", "StoreError::NotFound ⇒ AppError::NotFound, got {err:?}" ); } // --------------------------------------------------------------------------- // DescribeEmbedderEngines // --------------------------------------------------------------------------- fn onnx_view() -> OnnxModelView { OnnxModelView { id: "multilingual-e5-small".to_owned(), display_name: "Multilingual E5 Small".to_owned(), dimension: 384, approx_size_mb: 118, recommended: true, } } #[tokio::test] async fn describe_engines_merges_catalogue_report_and_flags() { let report = EmbedderEnvReport { ollama_detected: true, onnx_cached_models: vec!["multilingual-e5-small".to_owned()], }; let inspector = Arc::new(FixedEnvInspector(report)); let describe = DescribeEmbedderEngines::new( inspector, vec![onnx_view()], /* vector_http_enabled */ true, /* vector_onnx_enabled */ false, ); let view = describe.execute().await.unwrap(); // Catalogue injected verbatim. assert_eq!(view.recommended_onnx, vec![onnx_view()]); // Report fields surfaced. assert!(view.ollama_detected); assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]); // Compiled-capability flags surfaced as injected. assert!(view.vector_http_enabled); assert!(!view.vector_onnx_enabled); } #[tokio::test] async fn describe_engines_with_nothing_detected() { let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport { ollama_detected: false, onnx_cached_models: vec![], })); let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true); let view = describe.execute().await.unwrap(); assert!(view.recommended_onnx.is_empty()); assert!(!view.ollama_detected); assert!(view.onnx_cached_models.is_empty()); assert!(!view.vector_http_enabled); assert!(view.vector_onnx_enabled); }