From abfa5b9d30b758a66ec086c314d3820c6d3cc443 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 11:48:11 +0300 Subject: [PATCH 01/45] Expand contract and facade test coverage Co-authored-by: Medulla --- .github/workflows/ci.yml | 18 ++- crates/tinymemory-api/src/host/config.rs | 43 ++++++- crates/tinymemory-api/src/host/embeddings.rs | 39 ++++++- crates/tinymemory-api/src/host/local_ai.rs | 75 +++++++++++- .../tinymemory-api/src/host/scheduler_gate.rs | 31 +++++ .../tinymemory-api/src/host/storage_memory.rs | 22 ++++ crates/tinymemory-api/src/traits.rs | 4 + crates/tinymemory-api/src/traits_tests.rs | 110 ++++++++++++++++++ crates/tinymemory-bus/src/evidence.rs | 4 + crates/tinymemory-bus/src/evidence_tests.rs | 100 ++++++++++++++++ crates/tinymemory-bus/src/goals.rs | 2 +- crates/tinymemory-bus/src/goals_tests.rs | 8 ++ .../src/reference/mod.rs | 5 +- .../tinymemory-conformance/src/suite/mod.rs | 105 ++++++++++++++--- crates/tinymemory/tests/feature_surface.rs | 49 ++++++++ 15 files changed, 587 insertions(+), 28 deletions(-) create mode 100644 crates/tinymemory-api/src/traits_tests.rs create mode 100644 crates/tinymemory-bus/src/evidence_tests.rs create mode 100644 crates/tinymemory/tests/feature_surface.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0613280..7aef5c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,10 +186,8 @@ jobs: # `DriverRegistry` admission is a static policy table rather than a function # of which adapters were compiled in. # - # Two of §E2's nine configurations name features the facade does not have. - # `--features contacts` belongs to `tinymemory-core` and is covered by the - # powerset job above. `--features sync-composio` names a feature that exists - # nowhere in the workspace: the Composio sync is unconditional in + # `--features sync-composio` names a feature that exists nowhere in the + # workspace: the Composio sync is unconditional in # `tinymemory-core`, so there is nothing to select and nothing to isolate. # Recorded here rather than quietly dropped, because a missing row in a # matrix reads as covered. @@ -208,6 +206,18 @@ jobs: features: --features tinycortex - name: tinycortex and memory-git features: --features tinycortex,memory-git + - name: memory-git implication + features: --no-default-features --features memory-git + - name: all engines aggregate + features: --no-default-features --features engines + - name: sources network implication + features: --no-default-features --features sources-network + - name: documents network implication + features: --no-default-features --features documents-network + - name: contacts implication + features: --no-default-features --features contacts + - name: full aggregate + features: --no-default-features --features full - name: mem0 features: --features mem0 - name: supermemory diff --git a/crates/tinymemory-api/src/host/config.rs b/crates/tinymemory-api/src/host/config.rs index 22920a0..f39f361 100644 --- a/crates/tinymemory-api/src/host/config.rs +++ b/crates/tinymemory-api/src/host/config.rs @@ -50,7 +50,7 @@ pub const COMPOSIO_MODE_DIRECT: &str = "direct"; /// `ComposioConfig` carries fields (toolkit triage opt-outs, the enabled flag) /// that have nothing to do with memory, and because borrowing it would pin the /// host's type into this contract. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Clone, Default, PartialEq, Eq)] pub struct ComposioMode { /// [`COMPOSIO_MODE_BACKEND`] or [`COMPOSIO_MODE_DIRECT`]. pub mode: String, @@ -63,6 +63,17 @@ pub struct ComposioMode { pub triage_disabled: bool, } +impl std::fmt::Debug for ComposioMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ComposioMode") + .field("mode", &self.mode) + .field("entity_id", &self.entity_id) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field("triage_disabled", &self.triage_disabled) + .finish() + } +} + impl ComposioMode { /// True when the host routes Composio calls directly rather than through /// its cloud backend. @@ -273,3 +284,33 @@ pub trait MemoryHostConfig: Send + Sync + std::fmt::Debug { /// Propagates the host's own write/serialize failure. async fn save(&self) -> anyhow::Result<()>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn composio_direct_mode_is_ascii_case_insensitive() { + assert!(ComposioMode { + mode: "DIRECT".into(), + ..Default::default() + } + .is_direct()); + assert!(!ComposioMode { + mode: COMPOSIO_MODE_BACKEND.into(), + ..Default::default() + } + .is_direct()); + } + + #[test] + fn composio_debug_output_redacts_the_api_key() { + let mode = ComposioMode { + api_key: Some("composio-secret".into()), + ..Default::default() + }; + let debug = format!("{mode:?}"); + assert!(!debug.contains("composio-secret")); + assert!(debug.contains("")); + } +} diff --git a/crates/tinymemory-api/src/host/embeddings.rs b/crates/tinymemory-api/src/host/embeddings.rs index 4f14b37..e6ac227 100644 --- a/crates/tinymemory-api/src/host/embeddings.rs +++ b/crates/tinymemory-api/src/host/embeddings.rs @@ -60,7 +60,9 @@ fn escape_component(value: &str) -> String { #[cfg(test)] mod embedding_signature_tests { - use super::format_embedding_signature; + use async_trait::async_trait; + + use super::{format_embedding_signature, EmbeddingProvider, NoopEmbedding}; /// The signature format is a **persisted key**, pinned to literal values. /// @@ -126,6 +128,41 @@ mod embedding_signature_tests { ); } } + + #[tokio::test] + async fn noop_returns_one_empty_vector_per_input() { + let provider = NoopEmbedding; + assert_eq!(provider.signature(), "provider=none;model=none;dims=0"); + assert_eq!( + provider.embed(&["a", "b"]).await.unwrap(), + vec![Vec::::new(), Vec::::new()] + ); + assert!(provider.embed_one("a").await.unwrap().is_empty()); + } + + struct EmptyProvider; + + #[async_trait] + impl EmbeddingProvider for EmptyProvider { + fn name(&self) -> &str { + "empty" + } + fn model_id(&self) -> &str { + "empty" + } + fn dimensions(&self) -> usize { + 0 + } + async fn embed(&self, _: &[&str]) -> anyhow::Result>> { + Ok(Vec::new()) + } + } + + #[tokio::test] + async fn embed_one_rejects_a_provider_that_returns_no_vectors() { + let error = EmptyProvider.embed_one("text").await.unwrap_err(); + assert!(error.to_string().contains("Empty embedding result")); + } } /// Converts text into numerical vectors. diff --git a/crates/tinymemory-api/src/host/local_ai.rs b/crates/tinymemory-api/src/host/local_ai.rs index f5cee1e..91773fa 100644 --- a/crates/tinymemory-api/src/host/local_ai.rs +++ b/crates/tinymemory-api/src/host/local_ai.rs @@ -29,7 +29,7 @@ pub struct LocalAiUsage { pub subconscious: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct LocalAiConfig { /// Master runtime switch. Defaults to `false` — local AI is OFF by default. @@ -117,6 +117,41 @@ pub struct LocalAiConfig { pub usage: LocalAiUsage, } +impl std::fmt::Debug for LocalAiConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalAiConfig") + .field("runtime_enabled", &self.runtime_enabled) + .field("provider", &self.provider) + .field("base_url", &self.base_url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .field("model_id", &self.model_id) + .field("chat_model_id", &self.chat_model_id) + .field("vision_model_id", &self.vision_model_id) + .field("embedding_model_id", &self.embedding_model_id) + .field("stt_model_id", &self.stt_model_id) + .field("stt_download_url", &self.stt_download_url) + .field("stt_provider", &self.stt_provider) + .field("tts_voice_id", &self.tts_voice_id) + .field("tts_provider", &self.tts_provider) + .field("tts_download_url", &self.tts_download_url) + .field("tts_config_download_url", &self.tts_config_download_url) + .field("quantization", &self.quantization) + .field("preload_vision_model", &self.preload_vision_model) + .field("preload_embedding_model", &self.preload_embedding_model) + .field("preload_stt_model", &self.preload_stt_model) + .field("preload_tts_voice", &self.preload_tts_voice) + .field("download_url", &self.download_url) + .field("autosummary_debounce_ms", &self.autosummary_debounce_ms) + .field("selected_tier", &self.selected_tier) + .field("opt_in_confirmed", &self.opt_in_confirmed) + .field("ollama_binary_path", &self.ollama_binary_path) + .field("voice_llm_cleanup_enabled", &self.voice_llm_cleanup_enabled) + .field("num_ctx", &self.num_ctx) + .field("usage", &self.usage) + .finish() + } +} + fn default_runtime_enabled() -> bool { false } @@ -283,3 +318,41 @@ impl Default for LocalAiConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_keep_local_runtime_and_every_usage_gate_off() { + let cfg = LocalAiConfig::default(); + assert!(!cfg.is_active()); + #[allow(deprecated)] + { + assert!(!cfg.use_local_for_embeddings()); + assert!(!cfg.use_local_for_heartbeat()); + assert!(!cfg.use_local_for_learning()); + assert!(!cfg.use_local_for_subconscious()); + } + assert_eq!(cfg.embedding_model_id, "bge-m3"); + } + + #[test] + fn debug_output_redacts_the_api_key() { + let cfg = LocalAiConfig { + api_key: Some("secret-key".into()), + ..Default::default() + }; + let debug = format!("{cfg:?}"); + assert!(!debug.contains("secret-key")); + assert!(debug.contains("")); + assert!(debug.contains("voice_llm_cleanup_enabled: true")); + assert!(debug.contains("usage: LocalAiUsage")); + } + + #[test] + fn legacy_enabled_key_does_not_reenable_the_runtime() { + let cfg: LocalAiConfig = toml::from_str("enabled = true").unwrap(); + assert!(!cfg.runtime_enabled); + } +} diff --git a/crates/tinymemory-api/src/host/scheduler_gate.rs b/crates/tinymemory-api/src/host/scheduler_gate.rs index 9d3b4e3..6d46ebf 100644 --- a/crates/tinymemory-api/src/host/scheduler_gate.rs +++ b/crates/tinymemory-api/src/host/scheduler_gate.rs @@ -186,3 +186,34 @@ impl Policy { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scheduler_defaults_are_pinned_to_safe_auto_limits() { + let config = SchedulerGateConfig::default(); + assert_eq!(config.mode, SchedulerGateMode::Auto); + assert_eq!(config.battery_floor, 0.80); + assert_eq!(config.cpu_busy_threshold_pct, 70.0); + assert_eq!(config.cpu_severe_pct, 95.0); + assert_eq!(config.throttled_backoff_ms, 30_000); + assert_eq!(config.paused_poll_ms, 60_000); + assert!(!config.require_ac_power); + } + + #[test] + fn scheduler_mode_serde_and_policy_strings_are_stable() { + let config: SchedulerGateConfig = toml::from_str("mode = \"always_on\"").unwrap(); + assert_eq!(config.mode, SchedulerGateMode::AlwaysOn); + assert_eq!(config.mode.as_str(), "always_on"); + let paused = Policy::Paused { + reason: PauseReason::SignedOut, + }; + assert_eq!(paused.as_str(), "paused"); + assert_eq!(paused.pause_reason(), Some(PauseReason::SignedOut)); + assert_eq!(PauseReason::SignedOut.as_str(), "signed_out"); + assert_eq!(Policy::Normal.pause_reason(), None); + } +} diff --git a/crates/tinymemory-api/src/host/storage_memory.rs b/crates/tinymemory-api/src/host/storage_memory.rs index 928f1da..241be88 100644 --- a/crates/tinymemory-api/src/host/storage_memory.rs +++ b/crates/tinymemory-api/src/host/storage_memory.rs @@ -558,4 +558,26 @@ mod tests { assert_eq!(apply(""), None); assert_eq!(apply(" "), None); } + + #[test] + fn memory_config_debug_redacts_agentmemory_secret() { + let cfg = MemoryConfig { + agentmemory_secret: Some("bearer-secret".into()), + ..Default::default() + }; + let debug = format!("{cfg:?}"); + assert!(!debug.contains("bearer-secret")); + assert!(debug.contains("")); + } + + #[test] + fn memory_config_deserialization_supplies_operational_defaults() { + let cfg: MemoryConfig = toml::from_str("").unwrap(); + assert_eq!(cfg.backend, "sqlite"); + assert!(cfg.auto_save); + assert_eq!(cfg.embedding_provider, "cloud"); + assert_eq!(cfg.embedding_model, "embedding-v1"); + assert_eq!(cfg.embedding_dimensions, 1024); + assert_eq!(cfg.embedding_rate_limit_per_min, 60); + } } diff --git a/crates/tinymemory-api/src/traits.rs b/crates/tinymemory-api/src/traits.rs index bf48865..04967b3 100644 --- a/crates/tinymemory-api/src/traits.rs +++ b/crates/tinymemory-api/src/traits.rs @@ -168,3 +168,7 @@ pub trait Memory: Send + Sync { None } } + +#[cfg(test)] +#[path = "traits_tests.rs"] +mod tests; diff --git a/crates/tinymemory-api/src/traits_tests.rs b/crates/tinymemory-api/src/traits_tests.rs new file mode 100644 index 0000000..a7157e5 --- /dev/null +++ b/crates/tinymemory-api/src/traits_tests.rs @@ -0,0 +1,110 @@ +//! Tests for fail-closed defaults on [`super::Memory`]. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; + +use super::Memory; +use crate::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts}; + +#[derive(Default)] +struct MinimalMemory { + stores: AtomicUsize, +} + +#[async_trait] +impl Memory for MinimalMemory { + fn name(&self) -> &str { + "minimal" + } + + async fn store( + &self, + _: &str, + _: &str, + _: &str, + _: MemoryCategory, + _: Option<&str>, + ) -> anyhow::Result<()> { + self.stores.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + async fn recall( + &self, + _: &str, + _: usize, + _: RecallOpts<'_>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn get(&self, _: &str, _: &str) -> anyhow::Result> { + Ok(None) + } + async fn list( + &self, + _: Option<&str>, + _: Option<&MemoryCategory>, + _: Option<&str>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn forget(&self, _: &str, _: &str) -> anyhow::Result { + Ok(false) + } + async fn namespace_summaries(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + async fn count(&self) -> anyhow::Result { + Ok(0) + } + async fn health_check(&self) -> bool { + true + } +} + +#[tokio::test] +async fn default_taint_storage_delegates_only_for_internal_content() { + let memory = MinimalMemory::default(); + memory + .store_with_taint( + "ns", + "key", + "value", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap(); + assert_eq!(memory.stores.load(Ordering::Relaxed), 1); + + let error = memory + .store_with_taint( + "ns", + "external", + "value", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("taint-preserving")); + assert_eq!( + memory.stores.load(Ordering::Relaxed), + 1, + "external content must not reach a backend that would drop its taint" + ); +} + +#[tokio::test] +async fn optional_memory_defaults_are_empty_and_unhealthy_detail_is_absent() { + let memory = MinimalMemory::default(); + assert!(memory + .recall_relevant_by_vector("ns", "q", 10, 0.5) + .await + .unwrap() + .is_empty()); + assert_eq!(memory.health_probe().await, None); +} diff --git a/crates/tinymemory-bus/src/evidence.rs b/crates/tinymemory-bus/src/evidence.rs index 53f1a78..a5d915a 100644 --- a/crates/tinymemory-bus/src/evidence.rs +++ b/crates/tinymemory-bus/src/evidence.rs @@ -79,3 +79,7 @@ pub enum EvidenceRef { window_label: String, }, } + +#[cfg(test)] +#[path = "evidence_tests.rs"] +mod tests; diff --git a/crates/tinymemory-bus/src/evidence_tests.rs b/crates/tinymemory-bus/src/evidence_tests.rs new file mode 100644 index 0000000..4beeab1 --- /dev/null +++ b/crates/tinymemory-bus/src/evidence_tests.rs @@ -0,0 +1,100 @@ +//! Tests for the persisted [`super::EvidenceRef`] representation. + +use super::EvidenceRef; + +#[test] +fn every_evidence_variant_round_trips_with_its_stable_discriminator( +) -> Result<(), serde_json::Error> { + let cases = [ + (EvidenceRef::Episodic { episodic_id: 42 }, "episodic"), + ( + EvidenceRef::EpisodicWindow { + from_id: 1, + to_id: 9, + }, + "episodic_window", + ), + ( + EvidenceRef::SourceSummary { + summary_id: "sum-1".into(), + }, + "source_summary", + ), + ( + EvidenceRef::TreeTopic { + topic_id: "topic-1".into(), + }, + "tree_topic", + ), + ( + EvidenceRef::DocumentChunk { + source_id: "source-1".into(), + chunk_id: "chunk-1".into(), + }, + "document_chunk", + ), + ( + EvidenceRef::EmailMessage { + source_id: "mailbox-1".into(), + message_id: "message-1".into(), + }, + "email_message", + ), + ( + EvidenceRef::Provider { + toolkit: "github".into(), + connection_id: "conn-1".into(), + field: "login".into(), + }, + "provider", + ), + ( + EvidenceRef::ToolCall { + tool_name: "search".into(), + episodic_id: 7, + }, + "tool_call", + ), + ( + EvidenceRef::TreeSourceWeight { + window_label: "recent".into(), + }, + "tree_source_weight", + ), + ]; + + for (evidence, discriminator) in cases { + let value = serde_json::to_value(&evidence)?; + assert_eq!(value["type"], discriminator); + assert_eq!(serde_json::from_value::(value)?, evidence); + } + Ok(()) +} + +#[test] +fn provider_evidence_pins_every_persisted_json_field() -> Result<(), serde_json::Error> { + let evidence = EvidenceRef::Provider { + toolkit: "github".into(), + connection_id: "conn-7".into(), + field: "login".into(), + }; + let literal = serde_json::json!({ + "type": "provider", + "toolkit": "github", + "connection_id": "conn-7", + "field": "login" + }); + + assert_eq!(serde_json::to_value(&evidence)?, literal); + assert_eq!(serde_json::from_value::(literal)?, evidence); + Ok(()) +} + +#[test] +fn unknown_evidence_discriminators_fail_closed() { + let error = serde_json::from_value::(serde_json::json!({ + "type": "future_untrusted_kind", + "content": "must not be guessed" + })); + assert!(error.is_err()); +} diff --git a/crates/tinymemory-bus/src/goals.rs b/crates/tinymemory-bus/src/goals.rs index 697a867..d1a8100 100644 --- a/crates/tinymemory-bus/src/goals.rs +++ b/crates/tinymemory-bus/src/goals.rs @@ -110,7 +110,7 @@ impl GoalsDoc { /// Allocate the next free `g` id not already used in the list. pub fn next_id(&self) -> String { - let mut n = self.items.len() + 1; + let mut n = 1; loop { let candidate = format!("g{n}"); if !self.items.iter().any(|i| i.id == candidate) { diff --git a/crates/tinymemory-bus/src/goals_tests.rs b/crates/tinymemory-bus/src/goals_tests.rs index e67410c..6a2eb81 100644 --- a/crates/tinymemory-bus/src/goals_tests.rs +++ b/crates/tinymemory-bus/src/goals_tests.rs @@ -20,3 +20,11 @@ fn parse_ignores_non_item_lines() { assert_eq!(doc.items[0].id, "g1"); assert_eq!(doc.items[0].text, "real goal"); } + +#[test] +fn next_id_returns_the_lowest_free_numeric_id() { + let doc = GoalsDoc { + items: vec![GoalItem::new("g1", "one"), GoalItem::new("g3", "three")], + }; + assert_eq!(doc.next_id(), "g2"); +} diff --git a/crates/tinymemory-conformance/src/reference/mod.rs b/crates/tinymemory-conformance/src/reference/mod.rs index 16c70ce..d0eb2c3 100644 --- a/crates/tinymemory-conformance/src/reference/mod.rs +++ b/crates/tinymemory-conformance/src/reference/mod.rs @@ -167,8 +167,11 @@ impl MemoryRecall for InMemoryProvider { query: &str, limit: usize, opts: &OwnedRecallOpts, - _scope: Option<&SourceScope>, + scope: Option<&SourceScope>, ) -> Result, MemoryError> { + if scope.is_some_and(SourceScope::is_empty) { + return Ok(Vec::new()); + } let needle = query.to_lowercase(); Ok(self .rows()? diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index f6d3fa6..7ea359b 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -25,7 +25,7 @@ use std::sync::Arc; use tinymemory_api::capabilities::Capability; use tinymemory_api::error::MemoryError; -use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider}; +use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider, SourceScope}; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::types::{MemoryCategory, MemoryTaint}; @@ -61,6 +61,7 @@ pub async fn assert_provider(provider: Arc) { assert_list_filters_narrow(p).await; assert_taint_is_preserved(p).await; assert_recall_respects_limit_and_namespace(p).await; + assert_recall_respects_source_scope(p).await; assert_export_import_round_trip(p).await; assert_awkward_content_round_trips(p).await; assert_kv_round_trip(p).await; @@ -379,10 +380,11 @@ pub async fn assert_list_filters_narrow(provider: &dyn MemoryProvider) { .namespaces() .await .unwrap_or_else(|e| panic!("{who}: namespaces failed: {e}")); - let mine = summaries.iter().find(|s| s.namespace == ns); - if let Some(summary) = mine { - assert_eq!(summary.count, 2, "{who}: namespace summary miscounted"); - } + let summary = summaries + .iter() + .find(|s| s.namespace == ns) + .unwrap_or_else(|| panic!("{who}: namespaces omitted a namespace containing two rows")); + assert_eq!(summary.count, 2, "{who}: namespace summary miscounted"); cleanup(provider, &ns, &["core-a", "daily-b"]).await; } @@ -470,6 +472,11 @@ pub async fn assert_recall_respects_limit_and_namespace(provider: &dyn MemoryPro "{who}: recall returned {} hits for a limit of 2", hits.len() ); + assert_eq!( + hits.len(), + 2, + "{who}: recall returned too few matching rows; an empty recall must not conform" + ); for hit in &hits { assert_eq!( hit.namespace.as_deref(), @@ -482,6 +489,45 @@ pub async fn assert_recall_respects_limit_and_namespace(provider: &dyn MemoryPro cleanup(provider, &theirs, &["other"]).await; } +/// A present, empty source scope fails closed. +/// +/// # Panics +/// +/// Panics when a driver ignores an empty [`SourceScope`] and returns content. +pub async fn assert_recall_respects_source_scope(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "recall-scope"); + provider + .store( + &ns, + "scoped", + "source scoped needle", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + let opts = OwnedRecallOpts { + namespace: Some(ns.clone()), + ..Default::default() + }; + match provider + .recall("needle", 8, &opts, Some(&SourceScope::default())) + .await + { + Ok(hits) => assert!( + hits.is_empty(), + "{who}: recall ignored an empty source scope and returned {hits:?}" + ), + // A driver whose recall path cannot apply the predicate must refuse + // the call. This is still fail-closed; answering it unscoped is not. + Err(MemoryError::Invalid(_)) => {} + Err(other) => panic!("{who}: scoped recall failed with the wrong error class: {other}"), + } + cleanup(provider, &ns, &["scoped"]).await; +} + /// Exported records re-import with their taint intact. /// /// # Panics @@ -533,7 +579,22 @@ pub async fn assert_export_import_round_trip(provider: &dyn MemoryProvider) { .unwrap_or_else(|| panic!("{who}: export dropped the record's ExternalSync taint")); assert_eq!(exported.taint, MemoryTaint::ExternalSync); - provider.forget(&ns, "p1").await.ok(); + let removed = provider + .forget(&ns, "p1") + .await + .unwrap_or_else(|e| panic!("{who}: forget before import failed: {e}")); + assert!( + removed, + "{who}: forget before import reported that the exported record was absent" + ); + let absent = provider + .get(&ns, "p1") + .await + .unwrap_or_else(|e| panic!("{who}: get after forget failed: {e}")); + assert!( + absent.is_none(), + "{who}: record remained readable before import, so the restore was not verified" + ); let outcome = provider .import_records(mine.clone()) .await @@ -550,13 +611,21 @@ pub async fn assert_export_import_round_trip(provider: &dyn MemoryProvider) { ); } - if let Some(back) = provider.get(&ns, "p1").await.unwrap_or(None) { - assert_eq!( - back.taint, - MemoryTaint::ExternalSync, - "{who}: import re-stamped provenance instead of persisting what it was given" - ); - } + assert_eq!( + outcome.imported as usize, + mine.len(), + "{who}: import did not report every accepted record" + ); + let back = provider + .get(&ns, "p1") + .await + .unwrap_or_else(|e| panic!("{who}: get after import failed: {e}")) + .unwrap_or_else(|| panic!("{who}: import reported success but restored no record")); + assert_eq!( + back.taint, + MemoryTaint::ExternalSync, + "{who}: import re-stamped provenance instead of persisting what it was given" + ); cleanup(provider, &ns, &["p1"]).await; } @@ -581,12 +650,10 @@ pub async fn assert_export_cursor_terminates(provider: &dyn MemoryProvider) { // A cursor this driver never issued must be refused rather than silently // restarting the export from the beginning, which would duplicate rows. let bogus = provider.export_page(Some("!not-a-cursor!"), 8).await; - if let Ok(page) = bogus { - assert!( - page.records.is_empty(), - "{who}: an unrecognised cursor returned records instead of being refused" - ); - } + assert!( + matches!(bogus, Err(MemoryError::Invalid(_))), + "{who}: an unrecognised cursor must return Invalid, got {bogus:?}" + ); } /// Unicode, empty, and oversized content survive a round trip. diff --git a/crates/tinymemory/tests/feature_surface.rs b/crates/tinymemory/tests/feature_surface.rs new file mode 100644 index 0000000..d6b7db2 --- /dev/null +++ b/crates/tinymemory/tests/feature_surface.rs @@ -0,0 +1,49 @@ +//! Public facade and Cargo feature implication tests. + +#[test] +fn facade_reexports_the_contract_types_without_conversion() { + fn accepts_api_category(_: tinymemory::api::types::MemoryCategory) {} + let category = tinymemory::types::MemoryCategory::Core; + accepts_api_category(category); + + let provider = tinymemory::null::NullMemoryProvider::new(); + let _: &dyn tinymemory::provider::MemoryProvider = &provider; +} + +#[cfg(all(feature = "sources-network", not(feature = "sources")))] +compile_error!("sources-network must imply sources"); +#[cfg(all(feature = "documents-network", not(feature = "documents")))] +compile_error!("documents-network must imply documents"); +#[cfg(all(feature = "memory-git", not(feature = "tinycortex")))] +compile_error!("memory-git must imply tinycortex"); +#[cfg(all(feature = "contacts", not(feature = "core")))] +compile_error!("contacts must imply core"); +#[cfg(all( + feature = "engines", + not(all( + feature = "tinycortex", + feature = "supermemory", + feature = "mem0", + feature = "cognee" + )) +))] +compile_error!("engines must expose every engine adapter"); +#[cfg(all( + feature = "full", + not(all( + feature = "engines", + feature = "core", + feature = "sync", + feature = "sources-network", + feature = "documents-network", + feature = "conformance", + feature = "memory-git" + )) +))] +compile_error!("full must imply every production feature group"); + +#[cfg(feature = "conformance")] +#[test] +fn conformance_feature_exposes_the_reference_provider() { + let _ = tinymemory::conformance::InMemoryProvider::new(); +} From 03e26d35ce3c415a9071b4933cecdb89feeaba40 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 11:58:27 +0300 Subject: [PATCH 02/45] Expand core data pipeline test coverage Co-authored-by: Medulla --- .../tinymemory-core/src/host_seams_tests.rs | 250 ++++++++++++++++++ crates/tinymemory-core/src/lib.rs | 3 + .../composio/providers/slack_parse.rs | 13 +- .../composio/providers/slack_parse_tests.rs | 63 +++++ crates/tinymemory-core/src/sync_events.rs | 4 + .../tinymemory-core/src/sync_events_tests.rs | 91 +++++++ .../tinymemory-documents/src/ingest/test.rs | 46 ++++ crates/tinymemory-sources/src/raw_kind.rs | 4 + .../tinymemory-sources/src/raw_kind_tests.rs | 25 ++ .../src/readers/folder_tests.rs | 98 +++++++ crates/tinymemory-sync/src/email_markdown.rs | 80 ++++++ .../src/slack_post_process_tests.rs | 47 ++++ 12 files changed, 721 insertions(+), 3 deletions(-) create mode 100644 crates/tinymemory-core/src/host_seams_tests.rs create mode 100644 crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse_tests.rs create mode 100644 crates/tinymemory-core/src/sync_events_tests.rs create mode 100644 crates/tinymemory-sources/src/raw_kind_tests.rs diff --git a/crates/tinymemory-core/src/host_seams_tests.rs b/crates/tinymemory-core/src/host_seams_tests.rs new file mode 100644 index 0000000..87743e8 --- /dev/null +++ b/crates/tinymemory-core/src/host_seams_tests.rs @@ -0,0 +1,250 @@ +//! Tests for the process-global host integration seams. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use async_trait::async_trait; +use tinymemory_api::host::test_support::TestHostConfig; +use tokio::sync::Notify; + +use crate::scheduler_gate::{Policy, SchedulerGate}; +use crate::Config; + +static SEAM_LOCK: Mutex<()> = Mutex::new(()); + +fn seam_guard() -> MutexGuard<'static, ()> { + SEAM_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[derive(Debug)] +struct TestGate { + notify: Arc, + waited: Arc, +} + +#[async_trait] +impl SchedulerGate for TestGate { + fn current_policy(&self) -> Policy { + Policy::Paused { + reason: crate::scheduler_gate::PauseReason::UserDisabled, + } + } + + fn resume_notify(&self) -> Arc { + Arc::clone(&self.notify) + } + + async fn wait_for_capacity(&self) -> Option> { + self.waited.store(true, Ordering::SeqCst); + Some(Box::new(())) + } +} + +#[tokio::test] +async fn scheduler_gate_delegates_and_clear_restores_ungated_defaults() { + let _guard = seam_guard(); + let previous = crate::scheduler_gate::scheduler_gate(); + crate::scheduler_gate::clear_scheduler_gate(); + + assert_eq!(crate::scheduler_gate::current_policy(), Policy::Normal); + assert!(crate::scheduler_gate::wait_for_capacity().await.is_none()); + let idle = crate::scheduler_gate::resume_notify(); + assert!(Arc::ptr_eq(&idle, &crate::scheduler_gate::resume_notify())); + + let notify = Arc::new(Notify::new()); + let waited = Arc::new(AtomicBool::new(false)); + crate::scheduler_gate::set_scheduler_gate(Arc::new(TestGate { + notify: Arc::clone(¬ify), + waited: Arc::clone(&waited), + })); + + assert!(matches!( + crate::scheduler_gate::current_policy(), + Policy::Paused { .. } + )); + assert!(Arc::ptr_eq( + ¬ify, + &crate::scheduler_gate::resume_notify() + )); + assert!(crate::scheduler_gate::wait_for_capacity().await.is_some()); + assert!(waited.load(Ordering::SeqCst)); + + match previous { + Some(gate) => crate::scheduler_gate::set_scheduler_gate(gate), + None => crate::scheduler_gate::clear_scheduler_gate(), + } +} + +#[derive(Debug)] +struct TestLoader; + +#[async_trait] +impl crate::config_loader::ConfigLoader for TestLoader { + async fn load(&self) -> Result, String> { + let mut config = TestHostConfig::default(); + config.output_language = Some("fr".to_string()); + Ok(Box::new(config)) + } + + async fn reload_snapshot(&self, _snapshot: &Config) -> Result, String> { + let mut config = TestHostConfig::default(); + config.output_language = Some("de".to_string()); + Ok(Arc::new(config)) + } +} + +#[tokio::test] +async fn config_loader_reports_unwired_and_delegates_both_load_paths() { + let _guard = seam_guard(); + let previous = crate::config_loader::config_loader(); + crate::config_loader::clear_config_loader(); + + let error = crate::config_loader::load_config_with_timeout() + .await + .expect_err("an unwired loader must fail loudly"); + assert!(error.contains("no ConfigLoader installed")); + + crate::config_loader::set_config_loader(Arc::new(TestLoader)); + let loaded = crate::config_loader::load_config_arc() + .await + .expect("test loader should load"); + assert_eq!(loaded.output_language(), Some("fr")); + let reloaded = crate::config_loader::reload_config_snapshot_with_timeout(loaded.as_ref()) + .await + .expect("test loader should reload"); + assert_eq!(reloaded.output_language(), Some("de")); + + match previous { + Some(loader) => crate::config_loader::set_config_loader(loader), + None => crate::config_loader::clear_config_loader(), + } +} + +#[derive(Default)] +struct TestShutdownHost { + hooks: parking_lot::Mutex>, +} + +impl std::fmt::Debug for TestShutdownHost { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TestShutdownHost") + .field("hook_count", &self.hooks.lock().len()) + .finish() + } +} + +impl crate::shutdown::ShutdownHost for TestShutdownHost { + fn register(&self, hook: crate::shutdown::ShutdownHook) { + self.hooks.lock().push(hook); + } +} + +#[tokio::test] +async fn shutdown_host_keeps_repeatable_hooks_and_unwired_registration_is_safe() { + let _guard = seam_guard(); + crate::shutdown::clear_shutdown_host(); + crate::shutdown::register(|| async {}); + + let host = Arc::new(TestShutdownHost::default()); + crate::shutdown::set_shutdown_host(Arc::clone(&host) as Arc); + let calls = Arc::new(AtomicUsize::new(0)); + crate::shutdown::register({ + let calls = Arc::clone(&calls); + move || { + let calls = Arc::clone(&calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + } + } + }); + + let hooks = host.hooks.lock(); + assert_eq!(hooks.len(), 1); + (hooks[0])().await; + (hooks[0])().await; + assert_eq!(calls.load(Ordering::SeqCst), 2); + drop(hooks); + crate::shutdown::clear_shutdown_host(); +} + +#[derive(Debug)] +struct TestNlpHost; + +#[async_trait] +impl crate::nlp_host::NlpHost for TestNlpHost { + async fn extract_spacy( + &self, + _config: &Config, + text: &str, + ) -> Result { + Ok(crate::nlp_host::SpacyResponse { + entities: vec![crate::nlp_host::SpacyEntity { + text: text.to_string(), + label: "ORG".to_string(), + start: 0, + end: text.len() as u32, + }], + nouns: Vec::new(), + }) + } +} + +#[tokio::test] +async fn nlp_host_reports_unwired_then_returns_host_response() { + let _guard = seam_guard(); + let previous = crate::nlp_host::nlp_host(); + crate::nlp_host::clear_nlp_host(); + let config = TestHostConfig::default(); + let error = crate::nlp_host::extract_spacy(&config, "TinyMemory") + .await + .expect_err("an unwired NLP host must request fallback"); + assert_eq!(error, "no NlpHost installed"); + + crate::nlp_host::set_nlp_host(Arc::new(TestNlpHost)); + let response = crate::nlp_host::extract_spacy(&config, "TinyMemory") + .await + .expect("test NLP host should answer"); + assert_eq!(response.entities[0].text, "TinyMemory"); + + match previous { + Some(host) => crate::nlp_host::set_nlp_host(host), + None => crate::nlp_host::clear_nlp_host(), + } +} + +#[test] +fn required_host_seams_fail_loudly_when_unwired() { + let _guard = seam_guard(); + let chat = crate::chat_host::chat_host(); + let composio = crate::composio_host::composio_host(); + crate::chat_host::clear_chat_host(); + crate::composio_host::clear_composio_host(); + + assert!(crate::chat_host::require_chat_host() + .expect_err("chat host must be required") + .contains("no ChatHost installed")); + assert!(crate::composio_host::require_composio_host() + .expect_err("Composio host must be required") + .contains("no ComposioHost installed")); + let config = TestHostConfig::default(); + assert_eq!( + crate::chat_host::provider_for_role("memory", &config), + "unknown" + ); + assert_eq!( + crate::chat_host::summarizer_available(&config), + (false, "no chat host installed — summarisation cannot run") + ); + assert!(!crate::composio_host::is_available(&config)); + assert_eq!(crate::composio_host::api_key(&config), None); + + if let Some(host) = chat { + crate::chat_host::set_chat_host(host); + } + if let Some(host) = composio { + crate::composio_host::set_composio_host(host); + } +} diff --git a/crates/tinymemory-core/src/lib.rs b/crates/tinymemory-core/src/lib.rs index 885e8df..7110417 100644 --- a/crates/tinymemory-core/src/lib.rs +++ b/crates/tinymemory-core/src/lib.rs @@ -80,6 +80,9 @@ pub mod tree_policy; pub mod tree_source; pub mod util; +#[cfg(test)] +mod host_seams_tests; + // The host seam, re-exported so downstream code takes one dependency. These are // the *only* types this crate accepts from its host. pub use tinymemory_api::host::{ diff --git a/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse.rs b/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse.rs index 2419f60..8efb1e9 100644 --- a/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse.rs +++ b/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse.rs @@ -41,9 +41,12 @@ pub(super) fn next_cursor(data: &Value) -> Option { "/data/data/response_metadata/next_cursor", ] .iter() - .find_map(|path| data.pointer(path).and_then(Value::as_str)) - .map(str::trim) - .filter(|cursor| !cursor.is_empty()) + .find_map(|path| { + data.pointer(path) + .and_then(Value::as_str) + .map(str::trim) + .filter(|cursor| !cursor.is_empty()) + }) .map(str::to_owned) } @@ -89,3 +92,7 @@ pub(super) fn parse_ts(ts: &str) -> Option<(i64, u64)> { parts.next().unwrap_or("0").parse().ok()?, )) } + +#[cfg(test)] +#[path = "slack_parse_tests.rs"] +mod tests; diff --git a/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse_tests.rs b/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse_tests.rs new file mode 100644 index 0000000..be3224a --- /dev/null +++ b/crates/tinymemory-core/src/sync/pipelines/composio/providers/slack_parse_tests.rs @@ -0,0 +1,63 @@ +//! Tests for tolerant Slack cursor, mention, and timestamp parsing. + +use serde_json::json; + +use super::*; + +#[test] +fn mentions_resolve_known_users_and_keep_unknown_ids() { + let users = serde_json::Map::from_iter([ + ("U123".to_string(), json!("Ada")), + ("U999".to_string(), json!(17)), + ]); + assert_eq!( + replace_mentions("hi <@U123>, ask <@U456> and <@U999>", Some(&users)), + "hi @Ada, ask @U456 and @U999" + ); + assert_eq!(replace_mentions("plain", None), "plain"); +} + +#[test] +fn cursor_parser_skips_blank_values_and_understands_nested_envelopes() { + let data = json!({ + "data": { + "response_metadata": { "next_cursor": " " }, + "next_cursor": " page-2 " + } + }); + assert_eq!(next_cursor(&data).as_deref(), Some("page-2")); + assert_eq!(next_cursor(&json!({"next_cursor": 2})), None); +} + +#[test] +fn malformed_cursor_json_restarts_with_an_empty_map() { + assert!(decode_cursors(None).is_empty()); + assert!(decode_cursors(Some("not json")).is_empty()); + assert!(decode_cursors(Some("[1,2]")).is_empty()); + assert_eq!( + decode_cursors(Some(r#"{"C1":"100.2","C2":"200.0"}"#)) + .get("C2") + .map(String::as_str), + Some("200.0") + ); +} + +#[test] +fn timestamp_parser_rejects_malformed_numeric_components() { + assert_eq!(parse_ts("1714003200.000100"), Some((1_714_003_200, 100))); + assert_eq!(parse_ts("1714003200"), Some((1_714_003_200, 0))); + for malformed in ["", "abc.1", "12.abc", "12.", ".1"] { + assert_eq!(parse_ts(malformed), None, "accepted {malformed:?}"); + } +} + +#[test] +fn search_helpers_default_safely_for_malformed_payloads() { + assert!(search_matches(&json!(null)).is_empty()); + assert!(search_matches(&json!({"messages": "wrong"})).is_empty()); + assert_eq!(search_total_pages(&json!({"pages": "many"})), 1); + assert_eq!( + search_total_pages(&json!({"data":{"data":{"messages":{"paging":{"pages":7}}}}})), + 7 + ); +} diff --git a/crates/tinymemory-core/src/sync_events.rs b/crates/tinymemory-core/src/sync_events.rs index 36e96db..a7d24ea 100644 --- a/crates/tinymemory-core/src/sync_events.rs +++ b/crates/tinymemory-core/src/sync_events.rs @@ -115,3 +115,7 @@ pub fn extract_mem_src_id(composite_source_id: &str) -> Option<&str> { } Some(source_id) } + +#[cfg(test)] +#[path = "sync_events_tests.rs"] +mod tests; diff --git a/crates/tinymemory-core/src/sync_events_tests.rs b/crates/tinymemory-core/src/sync_events_tests.rs new file mode 100644 index 0000000..148f598 --- /dev/null +++ b/crates/tinymemory-core/src/sync_events_tests.rs @@ -0,0 +1,91 @@ +//! Tests for sync lifecycle values and source-id decoding. + +use super::*; +use crate::events::RecordingSink; + +#[test] +fn source_id_decoder_preserves_colons_in_item_ids_and_rejects_malformed_values() { + assert_eq!( + extract_mem_src_id("mem_src:feed_7:https://example.com/posts/1"), + Some("feed_7") + ); + assert_eq!( + extract_mem_src_id("mem_src:folder:notes/a.md"), + Some("folder") + ); + for malformed in [ + "slack:workspace-1", + "mem_src:", + "mem_src:source-only", + "mem_src:source:", + ] { + assert_eq!( + extract_mem_src_id(malformed), + None, + "accepted {malformed:?}" + ); + } +} + +#[test] +fn trigger_and_stage_strings_match_their_serde_wire_values() { + for (trigger, expected) in [ + (MemorySyncTrigger::Manual, "manual"), + (MemorySyncTrigger::Cron, "cron"), + ] { + assert_eq!(trigger.as_str(), expected); + assert_eq!(serde_json::to_value(trigger).unwrap(), expected); + } + for (stage, expected) in [ + (MemorySyncStage::Requested, "requested"), + (MemorySyncStage::Fetching, "fetching"), + (MemorySyncStage::Stored, "stored"), + (MemorySyncStage::Queued, "queued"), + (MemorySyncStage::Ingesting, "ingesting"), + (MemorySyncStage::Completed, "completed"), + (MemorySyncStage::Failed, "failed"), + ] { + assert_eq!(stage.as_str(), expected); + assert_eq!(serde_json::to_value(stage).unwrap(), expected); + } +} + +#[test] +fn emitting_a_sync_stage_preserves_all_optional_context() { + let previous = crate::events::event_sink(); + let sink = RecordingSink::install(); + emit_sync_stage( + MemorySyncTrigger::Manual, + MemorySyncStage::Failed, + Some("rss"), + Some("connection-4"), + Some("bad feed".to_string()), + Some("source-9"), + ); + + let events = sink.drain(); + assert_eq!(events.len(), 1); + match &events[0] { + crate::events::MemoryEvent::SyncStageChanged { + trigger, + stage, + provider, + connection_id, + detail, + source_id, + } => { + assert_eq!(trigger, "manual"); + assert_eq!(stage, "failed"); + assert_eq!(provider.as_deref(), Some("rss")); + assert_eq!(connection_id.as_deref(), Some("connection-4")); + assert_eq!(detail.as_deref(), Some("bad feed")); + assert_eq!(source_id.as_deref(), Some("source-9")); + } + event => panic!("unexpected event: {event:?}"), + } + + match previous { + Some(sink) => crate::events::set_event_sink(sink), + None => crate::events::clear_event_sink(), + } +} diff --git a/crates/tinymemory-documents/src/ingest/test.rs b/crates/tinymemory-documents/src/ingest/test.rs index 31b4c1c..76e0d5e 100644 --- a/crates/tinymemory-documents/src/ingest/test.rs +++ b/crates/tinymemory-documents/src/ingest/test.rs @@ -33,6 +33,7 @@ struct Recorded { struct FakeProvider { has_ingest: bool, has_documents: bool, + fail_writes: bool, recorded: Mutex, } @@ -41,6 +42,7 @@ impl FakeProvider { Self { has_ingest: true, has_documents: true, + fail_writes: false, recorded: Mutex::new(Recorded::default()), } } @@ -49,6 +51,7 @@ impl FakeProvider { Self { has_ingest: false, has_documents: true, + fail_writes: false, recorded: Mutex::new(Recorded::default()), } } @@ -57,6 +60,16 @@ impl FakeProvider { Self { has_ingest: false, has_documents: false, + fail_writes: false, + recorded: Mutex::new(Recorded::default()), + } + } + + fn failing(has_ingest: bool, has_documents: bool) -> Self { + Self { + has_ingest, + has_documents, + fail_writes: true, recorded: Mutex::new(Recorded::default()), } } @@ -80,6 +93,9 @@ impl MemoryCore for FakeProvider { _session_id: Option<&str>, _taint: MemoryTaint, ) -> Result<()> { + if self.fail_writes { + return Err(MemoryError::Backend("core write rejected".to_string())); + } self.recorded() .entries .push((namespace.to_string(), key.to_string(), content.to_string())); @@ -141,6 +157,9 @@ impl MemoryPortability for FakeProvider { #[async_trait] impl MemoryIngest for FakeProvider { async fn ingest_document(&self, item: IngestItem) -> Result { + if self.fail_writes { + return Err(MemoryError::Backend("ingest write rejected".to_string())); + } self.recorded().ingested.push(item); Ok(IngestOutcome { written: 4, @@ -157,6 +176,9 @@ impl MemoryIngest for FakeProvider { #[async_trait] impl MemoryDocuments for FakeProvider { async fn put_document(&self, input: NamespaceDocumentInput) -> Result { + if self.fail_writes { + return Err(MemoryError::Backend("document write rejected".to_string())); + } self.recorded().documents.push(input); Ok("doc-7".to_string()) } @@ -488,6 +510,30 @@ async fn a_receipt_reports_both_sizes() { assert_eq!(receipt.format, DocumentFormat::Html); } +#[tokio::test] +async fn driver_failures_propagate_from_every_intake_route() { + for (provider, expected) in [ + (FakeProvider::failing(true, true), "ingest write rejected"), + ( + FakeProvider::failing(false, true), + "document write rejected", + ), + (FakeProvider::failing(false, false), "core write rejected"), + ] { + let chain = ConverterChain::default(); + let error = DocumentIntake::new(&provider, &chain) + .accept(&markdown_upload(), &IntakeRequest::new("document:failure")) + .await + .unwrap_err(); + assert!(matches!(error, MemoryError::Backend(_)), "got {error:?}"); + assert!(error.to_string().contains(expected), "got {error}"); + let recorded = provider.recorded(); + assert!(recorded.ingested.is_empty()); + assert!(recorded.documents.is_empty()); + assert!(recorded.entries.is_empty()); + } +} + #[test] fn a_route_round_trips_through_its_wire_spelling() { for route in [ diff --git a/crates/tinymemory-sources/src/raw_kind.rs b/crates/tinymemory-sources/src/raw_kind.rs index d56c6a1..b58eba5 100644 --- a/crates/tinymemory-sources/src/raw_kind.rs +++ b/crates/tinymemory-sources/src/raw_kind.rs @@ -47,3 +47,7 @@ impl RawKind { } } } + +#[cfg(test)] +#[path = "raw_kind_tests.rs"] +mod tests; diff --git a/crates/tinymemory-sources/src/raw_kind_tests.rs b/crates/tinymemory-sources/src/raw_kind_tests.rs new file mode 100644 index 0000000..f38708f --- /dev/null +++ b/crates/tinymemory-sources/src/raw_kind_tests.rs @@ -0,0 +1,25 @@ +//! Tests for stable raw archive directory names. + +use super::RawKind; + +#[test] +fn every_raw_kind_has_a_distinct_plural_directory() { + let cases = [ + (RawKind::Email, "emails"), + (RawKind::Chat, "chats"), + (RawKind::Document, "documents"), + (RawKind::Contact, "contacts"), + (RawKind::Post, "posts"), + (RawKind::Commit, "commits"), + (RawKind::Issue, "issues"), + (RawKind::PullRequest, "prs"), + ]; + let mut directories = std::collections::HashSet::new(); + for (kind, expected) in cases { + assert_eq!(kind.as_dir(), expected); + assert!( + directories.insert(kind.as_dir()), + "duplicate directory {expected}" + ); + } +} diff --git a/crates/tinymemory-sources/src/readers/folder_tests.rs b/crates/tinymemory-sources/src/readers/folder_tests.rs index 7ebda63..b87583f 100644 --- a/crates/tinymemory-sources/src/readers/folder_tests.rs +++ b/crates/tinymemory-sources/src/readers/folder_tests.rs @@ -149,3 +149,101 @@ async fn read_item_missing_file_errors() { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } + +#[tokio::test] +async fn folder_source_without_a_path_is_rejected_for_list_and_read() { + let mut source = folder_source("unused"); + source.path = None; + let reader = FolderReader; + + for error in [ + reader.list_items(&source, config()).await.unwrap_err(), + reader + .read_item(&source, "note.md", config()) + .await + .unwrap_err(), + ] { + assert!(error.to_string().contains("folder source requires a path")); + } +} + +#[tokio::test] +async fn oversized_files_are_not_listed_and_cannot_be_read() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("huge.md"); + let file = fs::File::create(&path).unwrap(); + file.set_len(FOLDER_FILE_SIZE_CAP_BYTES + 1).unwrap(); + drop(file); + let source = folder_source(&tmp.path().to_string_lossy()); + let reader = FolderReader; + + assert!(reader + .list_items(&source, config()) + .await + .unwrap() + .is_empty()); + let error = reader + .read_item(&source, "huge.md", config()) + .await + .unwrap_err(); + assert!(error.to_string().contains("file exceeds")); +} + +#[tokio::test] +async fn invalid_utf8_is_reported_instead_of_lossily_decoded() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("binary.md"), [0xff, 0xfe, 0xfd]).unwrap(); + let source = folder_source(&tmp.path().to_string_lossy()); + let error = FolderReader + .read_item(&source, "binary.md", config()) + .await + .unwrap_err(); + assert!(error.to_string().to_ascii_lowercase().contains("utf-8")); +} + +#[tokio::test] +async fn content_type_follows_the_file_extension() { + let tmp = TempDir::new().unwrap(); + for (name, expected) in [ + ("page.html", ContentType::Html), + ("legacy.htm", ContentType::Html), + ("notes.txt", ContentType::Plaintext), + ] { + fs::write(tmp.path().join(name), "body").unwrap(); + let mut source = folder_source(&tmp.path().to_string_lossy()); + source.glob = Some("*".to_string()); + let content = FolderReader + .read_item(&source, name, config()) + .await + .unwrap(); + assert_eq!(content.content_type, expected); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn symlinks_cannot_escape_the_configured_folder() { + use std::os::unix::fs::symlink; + + let base = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + fs::write(outside.path().join("secret.md"), "secret").unwrap(); + symlink( + outside.path().join("secret.md"), + base.path().join("escape.md"), + ) + .unwrap(); + let source = folder_source(&base.path().to_string_lossy()); + let reader = FolderReader; + + assert!(reader + .list_items(&source, config()) + .await + .unwrap() + .is_empty()); + let error = reader + .read_item(&source, "escape.md", config()) + .await + .unwrap_err(); + assert!(matches!(error, MemoryError::PathEscape(_)), "got {error:?}"); +} diff --git a/crates/tinymemory-sync/src/email_markdown.rs b/crates/tinymemory-sync/src/email_markdown.rs index baff497..3325325 100644 --- a/crates/tinymemory-sync/src/email_markdown.rs +++ b/crates/tinymemory-sync/src/email_markdown.rs @@ -238,4 +238,84 @@ mod tests { "chunk separator must be escaped: {md}" ); } + + fn message_json(sent_at: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "from": "sender", + "subject": "subject", + "sent_at": sent_at, + "body": "body" + }) + } + + #[test] + fn flexible_timestamp_accepts_rfc3339_and_numeric_or_string_milliseconds() { + for value in [ + serde_json::json!("2026-01-02T03:04:05Z"), + serde_json::json!(1_767_323_045_000_i64), + serde_json::json!("1767323045000"), + ] { + let message: EmailMessage = serde_json::from_value(message_json(value)).unwrap(); + assert_eq!(message.sent_at.timestamp_millis(), 1_767_323_045_000); + } + } + + #[test] + fn flexible_timestamp_rejects_seconds_and_malformed_text() { + for value in [ + serde_json::json!(1_767_322_245_i64), + serde_json::json!("1767322245"), + serde_json::json!("last Tuesday"), + ] { + let error = serde_json::from_value::(message_json(value)).unwrap_err(); + assert!( + error.to_string().contains("milliseconds") + || error.to_string().contains("cannot parse"), + "unexpected error: {error}" + ); + } + } + + #[test] + fn rendering_sorts_oldest_first_and_escapes_header_markdown() { + let at = |timestamp: &str| { + DateTime::parse_from_rfc3339(timestamp) + .unwrap() + .with_timezone(&Utc) + }; + let thread = EmailThread { + provider: "gmail".into(), + thread_subject: "thread".into(), + messages: vec![ + EmailMessage { + from: "*new*".into(), + to: Vec::new(), + cc: Vec::new(), + subject: "[later]".into(), + sent_at: at("2026-02-01T00:00:00Z"), + body: "new".into(), + source_ref: None, + list_unsubscribe: None, + }, + EmailMessage { + from: "_old_".into(), + to: vec!["a|b".into()], + cc: vec!["c`d".into()], + subject: "# first".into(), + sent_at: at("2026-01-01T00:00:00Z"), + body: "old".into(), + source_ref: None, + list_unsubscribe: Some("".into()), + }, + ], + }; + + let markdown = thread_markdown(thread).unwrap(); + assert!(markdown.find("old").unwrap() < markdown.find("new").unwrap()); + assert!(markdown.contains("From: \\_old\\_")); + assert!(markdown.contains("To: a\\|b")); + assert!(markdown.contains("Cc: c\\`d")); + assert!(markdown.contains("Subject: # first")); + assert!(markdown.contains("List-Unsubscribe: ")); + } } diff --git a/crates/tinymemory-sync/src/slack_post_process_tests.rs b/crates/tinymemory-sync/src/slack_post_process_tests.rs index aeec63a..01e79ee 100644 --- a/crates/tinymemory-sync/src/slack_post_process_tests.rs +++ b/crates/tinymemory-sync/src/slack_post_process_tests.rs @@ -260,3 +260,50 @@ fn unknown_slug_is_noop() { post_process("SLACK_SEND_MESSAGE", None, &mut data); assert_eq!(data, original, "unknown slug must not mutate data"); } + +#[test] +fn malformed_history_payload_becomes_an_empty_stable_shape() { + for mut data in [json!(null), json!([]), json!({"messages": "wrong"})] { + post_process("SLACK_FETCH_CONVERSATION_HISTORY", None, &mut data); + assert_eq!(data, json!({"messages": []})); + } +} + +#[test] +fn malformed_channel_rows_are_dropped_and_defaults_are_stable() { + let mut data = json!({ + "channels": [ + null, + "not an object", + {"id": 7, "name": "numeric id"}, + {"id": " C1 ", "name": 99, "is_private": "yes"} + ] + }); + post_process("SLACK_LIST_CONVERSATIONS", None, &mut data); + assert_eq!( + data, + json!({"channels": [{"id":"C1", "name":"C1", "is_private":false}]}) + ); +} + +#[test] +fn malformed_search_rows_and_page_counts_fall_back_safely() { + let mut data = json!({ + "messages": { + "matches": [ + null, + {"ts":"1.0", "text":"no channel"}, + {"channel":{"id":"C1"}, "text":"no timestamp"}, + {"ts":"2.0", "channel":{"id":"C1"}, "text":" valid "} + ], + "paging": {"pages":"many"} + } + }); + post_process("SLACK_SEARCH_MESSAGES", None, &mut data); + assert_eq!(data["pages"], 1); + let messages = data["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["text"], "no channel"); + assert!(messages[0].get("channel_id").is_none()); + assert_eq!(messages[1]["text"], "valid"); +} From 4e9a5cf78aa76577943ce204f2062a537021e3c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 12:06:03 +0300 Subject: [PATCH 03/45] Make global seam tests deterministic Co-authored-by: Medulla --- crates/tinymemory-core/src/events.rs | 57 +++++- crates/tinymemory-core/src/lib.rs | 3 - crates/tinymemory-core/src/shutdown.rs | 8 +- crates/tinymemory-core/src/sync_events.rs | 4 - .../tinymemory-core/src/sync_events_tests.rs | 91 ---------- .../host_seams.rs} | 95 +++++++--- crates/tinymemory-core/tests/sync_events.rs | 162 ++++++++++++++++++ 7 files changed, 293 insertions(+), 127 deletions(-) delete mode 100644 crates/tinymemory-core/src/sync_events_tests.rs rename crates/tinymemory-core/{src/host_seams_tests.rs => tests/host_seams.rs} (81%) create mode 100644 crates/tinymemory-core/tests/sync_events.rs diff --git a/crates/tinymemory-core/src/events.rs b/crates/tinymemory-core/src/events.rs index 696c722..9ea647e 100644 --- a/crates/tinymemory-core/src/events.rs +++ b/crates/tinymemory-core/src/events.rs @@ -73,11 +73,26 @@ pub(crate) struct RecordingSink { #[cfg(test)] impl RecordingSink { - /// Install a fresh recorder and return it. Replaces any existing sink. - pub(crate) fn install() -> Arc { + /// Install a fresh recorder for the lifetime of the returned guard. + /// + /// All unit tests that replace the process-global sink share one lock. + /// Dropping the guard restores the previous sink only when this recorder + /// still owns the slot, so cleanup cannot clobber a newer host install. + pub(crate) fn install() -> RecordingSinkGuard { + static TEST_SINK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let lock = TEST_SINK_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = event_sink(); let sink = Arc::new(Self::default()); - set_event_sink(Arc::clone(&sink) as Arc); - sink + let installed = Arc::clone(&sink) as Arc; + set_event_sink(Arc::clone(&installed)); + RecordingSinkGuard { + sink, + installed, + previous, + _lock: lock, + } } /// Take everything recorded so far, leaving the recorder empty. @@ -86,6 +101,40 @@ impl RecordingSink { } } +/// Serialises unit tests that temporarily replace the global event sink. +#[cfg(test)] +pub(crate) struct RecordingSinkGuard { + sink: Arc, + installed: Arc, + previous: Option>, + _lock: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl std::ops::Deref for RecordingSinkGuard { + type Target = RecordingSink; + + fn deref(&self) -> &Self::Target { + &self.sink + } +} + +#[cfg(test)] +impl Drop for RecordingSinkGuard { + fn drop(&mut self) { + let still_installed = event_sink() + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &self.installed)); + if !still_installed { + return; + } + match self.previous.take() { + Some(previous) => set_event_sink(previous), + None => clear_event_sink(), + } + } +} + #[cfg(test)] impl MemoryEventSink for RecordingSink { fn publish(&self, event: MemoryEvent) { diff --git a/crates/tinymemory-core/src/lib.rs b/crates/tinymemory-core/src/lib.rs index 7110417..885e8df 100644 --- a/crates/tinymemory-core/src/lib.rs +++ b/crates/tinymemory-core/src/lib.rs @@ -80,9 +80,6 @@ pub mod tree_policy; pub mod tree_source; pub mod util; -#[cfg(test)] -mod host_seams_tests; - // The host seam, re-exported so downstream code takes one dependency. These are // the *only* types this crate accepts from its host. pub use tinymemory_api::host::{ diff --git a/crates/tinymemory-core/src/shutdown.rs b/crates/tinymemory-core/src/shutdown.rs index 553d0b5..fc1ffae 100644 --- a/crates/tinymemory-core/src/shutdown.rs +++ b/crates/tinymemory-core/src/shutdown.rs @@ -47,6 +47,12 @@ pub fn clear_shutdown_host() { *HOST.write() = None; } +/// The installed shutdown host, or `None` when nothing has been wired up. +#[must_use] +pub fn shutdown_host() -> Option> { + HOST.read().clone() +} + /// Register a hook to run before the process exits. /// /// A no-op beyond logging when no host is installed — see the module docs. @@ -55,7 +61,7 @@ where F: Fn() -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static, { - let host = HOST.read().clone(); + let host = shutdown_host(); match host { Some(host) => host.register(Box::new(move || Box::pin(hook()))), None => log::debug!( diff --git a/crates/tinymemory-core/src/sync_events.rs b/crates/tinymemory-core/src/sync_events.rs index a7d24ea..36e96db 100644 --- a/crates/tinymemory-core/src/sync_events.rs +++ b/crates/tinymemory-core/src/sync_events.rs @@ -115,7 +115,3 @@ pub fn extract_mem_src_id(composite_source_id: &str) -> Option<&str> { } Some(source_id) } - -#[cfg(test)] -#[path = "sync_events_tests.rs"] -mod tests; diff --git a/crates/tinymemory-core/src/sync_events_tests.rs b/crates/tinymemory-core/src/sync_events_tests.rs deleted file mode 100644 index 148f598..0000000 --- a/crates/tinymemory-core/src/sync_events_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Tests for sync lifecycle values and source-id decoding. - -use super::*; -use crate::events::RecordingSink; - -#[test] -fn source_id_decoder_preserves_colons_in_item_ids_and_rejects_malformed_values() { - assert_eq!( - extract_mem_src_id("mem_src:feed_7:https://example.com/posts/1"), - Some("feed_7") - ); - assert_eq!( - extract_mem_src_id("mem_src:folder:notes/a.md"), - Some("folder") - ); - for malformed in [ - "slack:workspace-1", - "mem_src:", - "mem_src:source-only", - "mem_src:source:", - ] { - assert_eq!( - extract_mem_src_id(malformed), - None, - "accepted {malformed:?}" - ); - } -} - -#[test] -fn trigger_and_stage_strings_match_their_serde_wire_values() { - for (trigger, expected) in [ - (MemorySyncTrigger::Manual, "manual"), - (MemorySyncTrigger::Cron, "cron"), - ] { - assert_eq!(trigger.as_str(), expected); - assert_eq!(serde_json::to_value(trigger).unwrap(), expected); - } - for (stage, expected) in [ - (MemorySyncStage::Requested, "requested"), - (MemorySyncStage::Fetching, "fetching"), - (MemorySyncStage::Stored, "stored"), - (MemorySyncStage::Queued, "queued"), - (MemorySyncStage::Ingesting, "ingesting"), - (MemorySyncStage::Completed, "completed"), - (MemorySyncStage::Failed, "failed"), - ] { - assert_eq!(stage.as_str(), expected); - assert_eq!(serde_json::to_value(stage).unwrap(), expected); - } -} - -#[test] -fn emitting_a_sync_stage_preserves_all_optional_context() { - let previous = crate::events::event_sink(); - let sink = RecordingSink::install(); - emit_sync_stage( - MemorySyncTrigger::Manual, - MemorySyncStage::Failed, - Some("rss"), - Some("connection-4"), - Some("bad feed".to_string()), - Some("source-9"), - ); - - let events = sink.drain(); - assert_eq!(events.len(), 1); - match &events[0] { - crate::events::MemoryEvent::SyncStageChanged { - trigger, - stage, - provider, - connection_id, - detail, - source_id, - } => { - assert_eq!(trigger, "manual"); - assert_eq!(stage, "failed"); - assert_eq!(provider.as_deref(), Some("rss")); - assert_eq!(connection_id.as_deref(), Some("connection-4")); - assert_eq!(detail.as_deref(), Some("bad feed")); - assert_eq!(source_id.as_deref(), Some("source-9")); - } - event => panic!("unexpected event: {event:?}"), - } - - match previous { - Some(sink) => crate::events::set_event_sink(sink), - None => crate::events::clear_event_sink(), - } -} diff --git a/crates/tinymemory-core/src/host_seams_tests.rs b/crates/tinymemory-core/tests/host_seams.rs similarity index 81% rename from crates/tinymemory-core/src/host_seams_tests.rs rename to crates/tinymemory-core/tests/host_seams.rs index 87743e8..57b4cb5 100644 --- a/crates/tinymemory-core/src/host_seams_tests.rs +++ b/crates/tinymemory-core/tests/host_seams.rs @@ -1,5 +1,35 @@ //! Tests for the process-global host integration seams. +// This is deliberately an integration-test binary. Its process globals are +// isolated from the library unit-test binary, where `test_seams::init` installs +// long-lived stubs behind a `Once`. + +mod scheduler_gate { + pub use tinymemory_core::scheduler_gate::*; +} + +mod config_loader { + pub use tinymemory_core::config_loader::*; +} + +mod shutdown { + pub use tinymemory_core::shutdown::*; +} + +mod nlp_host { + pub use tinymemory_core::nlp_host::*; +} + +mod chat_host { + pub use tinymemory_core::chat_host::*; +} + +mod composio_host { + pub use tinymemory_core::composio_host::*; +} + +type Config = tinymemory_core::Config; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -8,7 +38,6 @@ use tinymemory_api::host::test_support::TestHostConfig; use tokio::sync::Notify; use crate::scheduler_gate::{Policy, SchedulerGate}; -use crate::Config; static SEAM_LOCK: Mutex<()> = Mutex::new(()); @@ -18,6 +47,22 @@ fn seam_guard() -> MutexGuard<'static, ()> { .unwrap_or_else(|poisoned| poisoned.into_inner()) } +struct Restore(Option>); + +impl Restore { + fn new(restore: impl FnOnce() + 'static) -> Self { + Self(Some(Box::new(restore))) + } +} + +impl Drop for Restore { + fn drop(&mut self) { + if let Some(restore) = self.0.take() { + restore(); + } + } +} + #[derive(Debug)] struct TestGate { notify: Arc, @@ -46,6 +91,10 @@ impl SchedulerGate for TestGate { async fn scheduler_gate_delegates_and_clear_restores_ungated_defaults() { let _guard = seam_guard(); let previous = crate::scheduler_gate::scheduler_gate(); + let _restore = Restore::new(move || match previous { + Some(gate) => crate::scheduler_gate::set_scheduler_gate(gate), + None => crate::scheduler_gate::clear_scheduler_gate(), + }); crate::scheduler_gate::clear_scheduler_gate(); assert_eq!(crate::scheduler_gate::current_policy(), Policy::Normal); @@ -70,11 +119,6 @@ async fn scheduler_gate_delegates_and_clear_restores_ungated_defaults() { )); assert!(crate::scheduler_gate::wait_for_capacity().await.is_some()); assert!(waited.load(Ordering::SeqCst)); - - match previous { - Some(gate) => crate::scheduler_gate::set_scheduler_gate(gate), - None => crate::scheduler_gate::clear_scheduler_gate(), - } } #[derive(Debug)] @@ -99,6 +143,10 @@ impl crate::config_loader::ConfigLoader for TestLoader { async fn config_loader_reports_unwired_and_delegates_both_load_paths() { let _guard = seam_guard(); let previous = crate::config_loader::config_loader(); + let _restore = Restore::new(move || match previous { + Some(loader) => crate::config_loader::set_config_loader(loader), + None => crate::config_loader::clear_config_loader(), + }); crate::config_loader::clear_config_loader(); let error = crate::config_loader::load_config_with_timeout() @@ -115,11 +163,6 @@ async fn config_loader_reports_unwired_and_delegates_both_load_paths() { .await .expect("test loader should reload"); assert_eq!(reloaded.output_language(), Some("de")); - - match previous { - Some(loader) => crate::config_loader::set_config_loader(loader), - None => crate::config_loader::clear_config_loader(), - } } #[derive(Default)] @@ -145,6 +188,11 @@ impl crate::shutdown::ShutdownHost for TestShutdownHost { #[tokio::test] async fn shutdown_host_keeps_repeatable_hooks_and_unwired_registration_is_safe() { let _guard = seam_guard(); + let previous = crate::shutdown::shutdown_host(); + let _restore = Restore::new(move || match previous { + Some(host) => crate::shutdown::set_shutdown_host(host), + None => crate::shutdown::clear_shutdown_host(), + }); crate::shutdown::clear_shutdown_host(); crate::shutdown::register(|| async {}); @@ -167,7 +215,6 @@ async fn shutdown_host_keeps_repeatable_hooks_and_unwired_registration_is_safe() (hooks[0])().await; assert_eq!(calls.load(Ordering::SeqCst), 2); drop(hooks); - crate::shutdown::clear_shutdown_host(); } #[derive(Debug)] @@ -196,6 +243,10 @@ impl crate::nlp_host::NlpHost for TestNlpHost { async fn nlp_host_reports_unwired_then_returns_host_response() { let _guard = seam_guard(); let previous = crate::nlp_host::nlp_host(); + let _restore = Restore::new(move || match previous { + Some(host) => crate::nlp_host::set_nlp_host(host), + None => crate::nlp_host::clear_nlp_host(), + }); crate::nlp_host::clear_nlp_host(); let config = TestHostConfig::default(); let error = crate::nlp_host::extract_spacy(&config, "TinyMemory") @@ -208,11 +259,6 @@ async fn nlp_host_reports_unwired_then_returns_host_response() { .await .expect("test NLP host should answer"); assert_eq!(response.entities[0].text, "TinyMemory"); - - match previous { - Some(host) => crate::nlp_host::set_nlp_host(host), - None => crate::nlp_host::clear_nlp_host(), - } } #[test] @@ -220,6 +266,14 @@ fn required_host_seams_fail_loudly_when_unwired() { let _guard = seam_guard(); let chat = crate::chat_host::chat_host(); let composio = crate::composio_host::composio_host(); + let _chat_restore = Restore::new(move || match chat { + Some(host) => crate::chat_host::set_chat_host(host), + None => crate::chat_host::clear_chat_host(), + }); + let _composio_restore = Restore::new(move || match composio { + Some(host) => crate::composio_host::set_composio_host(host), + None => crate::composio_host::clear_composio_host(), + }); crate::chat_host::clear_chat_host(); crate::composio_host::clear_composio_host(); @@ -240,11 +294,4 @@ fn required_host_seams_fail_loudly_when_unwired() { ); assert!(!crate::composio_host::is_available(&config)); assert_eq!(crate::composio_host::api_key(&config), None); - - if let Some(host) = chat { - crate::chat_host::set_chat_host(host); - } - if let Some(host) = composio { - crate::composio_host::set_composio_host(host); - } } diff --git a/crates/tinymemory-core/tests/sync_events.rs b/crates/tinymemory-core/tests/sync_events.rs new file mode 100644 index 0000000..9d906fe --- /dev/null +++ b/crates/tinymemory-core/tests/sync_events.rs @@ -0,0 +1,162 @@ +//! Tests for sync lifecycle values and source-id decoding. + +use std::sync::{Arc, Mutex as StdMutex}; + +use parking_lot::Mutex; +use tinymemory_core::events::{self, MemoryEvent, MemoryEventSink}; +use tinymemory_core::sync_events::*; + +static SINK_LOCK: StdMutex<()> = StdMutex::new(()); + +#[derive(Debug, Default)] +struct RecordingSink { + events: Mutex>, +} + +impl RecordingSink { + fn drain(&self) -> Vec { + std::mem::take(&mut *self.events.lock()) + } +} + +impl MemoryEventSink for RecordingSink { + fn publish(&self, event: MemoryEvent) { + self.events.lock().push(event); + } +} + +/// Restores only if this test's sink still owns the global slot. A later host +/// install must never be overwritten by stale test cleanup. +struct SinkRestore { + previous: Option>, + installed: Arc, +} + +impl Drop for SinkRestore { + fn drop(&mut self) { + let owns_slot = events::event_sink() + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &self.installed)); + if !owns_slot { + return; + } + match self.previous.take() { + Some(previous) => events::set_event_sink(previous), + None => events::clear_event_sink(), + } + } +} + +#[test] +fn source_id_decoder_preserves_colons_in_item_ids_and_rejects_malformed_values() { + assert_eq!( + extract_mem_src_id("mem_src:feed_7:https://example.com/posts/1"), + Some("feed_7") + ); + assert_eq!( + extract_mem_src_id("mem_src:folder:notes/a.md"), + Some("folder") + ); + for malformed in [ + "slack:workspace-1", + "mem_src:", + "mem_src:source-only", + "mem_src:source:", + ] { + assert_eq!( + extract_mem_src_id(malformed), + None, + "accepted {malformed:?}" + ); + } +} + +#[test] +fn trigger_and_stage_strings_match_their_serde_wire_values() { + for (trigger, expected) in [ + (MemorySyncTrigger::Manual, "manual"), + (MemorySyncTrigger::Cron, "cron"), + ] { + assert_eq!(trigger.as_str(), expected); + assert_eq!(serde_json::to_value(trigger).unwrap(), expected); + } + for (stage, expected) in [ + (MemorySyncStage::Requested, "requested"), + (MemorySyncStage::Fetching, "fetching"), + (MemorySyncStage::Stored, "stored"), + (MemorySyncStage::Queued, "queued"), + (MemorySyncStage::Ingesting, "ingesting"), + (MemorySyncStage::Completed, "completed"), + (MemorySyncStage::Failed, "failed"), + ] { + assert_eq!(stage.as_str(), expected); + assert_eq!(serde_json::to_value(stage).unwrap(), expected); + } +} + +#[test] +fn emitting_a_sync_stage_preserves_all_optional_context() { + let _guard = SINK_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = events::event_sink(); + let sink = Arc::new(RecordingSink::default()); + let installed = Arc::clone(&sink) as Arc; + events::set_event_sink(Arc::clone(&installed)); + let _restore = SinkRestore { + previous, + installed, + }; + emit_sync_stage( + MemorySyncTrigger::Manual, + MemorySyncStage::Failed, + Some("rss"), + Some("connection-4"), + Some("bad feed".to_string()), + Some("source-9"), + ); + + let events = sink.drain(); + assert_eq!(events.len(), 1); + match &events[0] { + MemoryEvent::SyncStageChanged { + trigger, + stage, + provider, + connection_id, + detail, + source_id, + } => { + assert_eq!(trigger, "manual"); + assert_eq!(stage, "failed"); + assert_eq!(provider.as_deref(), Some("rss")); + assert_eq!(connection_id.as_deref(), Some("connection-4")); + assert_eq!(detail.as_deref(), Some("bad feed")); + assert_eq!(source_id.as_deref(), Some("source-9")); + } + event => panic!("unexpected event: {event:?}"), + } +} + +#[test] +fn stale_cleanup_does_not_overwrite_a_newer_sink_installation() { + let _guard = SINK_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let first = Arc::new(RecordingSink::default()); + let first_dyn = Arc::clone(&first) as Arc; + events::set_event_sink(Arc::clone(&first_dyn)); + let restore = SinkRestore { + previous: None, + installed: first_dyn, + }; + + let newer = Arc::new(RecordingSink::default()); + let newer_dyn = Arc::clone(&newer) as Arc; + events::set_event_sink(Arc::clone(&newer_dyn)); + drop(restore); + + let current = events::event_sink().expect("newer sink must remain installed"); + assert!(Arc::ptr_eq(¤t, &newer_dyn)); + events::clear_event_sink(); +} From 453cf83762b92b319dc78cc5d287bd04197f75cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 12:12:07 +0300 Subject: [PATCH 04/45] Isolate global health state tests Co-authored-by: Medulla --- crates/tinymemory-core/src/tree/health/mod.rs | 253 +----------------- .../tinymemory-core/tests/health_globals.rs | 245 +++++++++++++++++ 2 files changed, 246 insertions(+), 252 deletions(-) create mode 100644 crates/tinymemory-core/tests/health_globals.rs diff --git a/crates/tinymemory-core/src/tree/health/mod.rs b/crates/tinymemory-core/src/tree/health/mod.rs index 2bfb1a8..dd70f8e 100644 --- a/crates/tinymemory-core/src/tree/health/mod.rs +++ b/crates/tinymemory-core/src/tree/health/mod.rs @@ -281,262 +281,11 @@ pub fn current_degraded_state() -> DegradedState { mod tests { use super::*; - /// #5354 — a classified local-runtime failure flips the recall flag with - /// its own cause, so the panel names the Ollama fix from the first failed - /// embed instead of waiting out the retry budget. #[test] - fn local_model_unavailable_marks_recall_degraded_with_its_cause() { - let _g = test_guard(); - - mark_local_model_unavailable_if_applicable(&PipelineFailure::new( - FailureCode::LocalModelUnavailable, - )); - - let s = current_degraded_state(); - assert!(s.semantic_recall, "recall must be flagged degraded"); - assert_eq!( - s.cause.as_ref().map(|c| c.code), - Some(FailureCode::LocalModelUnavailable) - ); - assert_eq!( - s.cause.as_ref().map(|c| c.remediation_key.as_str()), - Some("memory.health.remediation.local_model_unavailable") - ); - } - - /// #5398 (codex) — the classifier is the ONLY producer of the durable - /// UserErrorCenter entry when Ollama is running but the model was never - /// pulled: the factory health gate probes `GET /api/tags`, which succeeds - /// in that case, so it never fires. It must broadcast on the transition - /// into the state, and must not re-broadcast per failed row afterwards. - #[test] - fn local_model_unavailable_broadcasts_once_per_transition() { - let _g = test_guard(); - let sink = crate::events::RecordingSink::install(); - - let failure = PipelineFailure::new(FailureCode::LocalModelUnavailable); - - // First failure of the outage → clients are told. - mark_local_model_unavailable_if_applicable(&failure); - let recorded = sink.drain(); - assert_eq!(recorded.len(), 1, "transition must broadcast"); - let event = &recorded[0]; - assert!( - matches!( - event, - crate::events::MemoryEvent::LocalModelUnavailable { .. } - ), - "the transition must publish the local-model-unavailable event, got {event:?}" - ); - - // Subsequent failures in the same outage must stay quiet — the re-embed - // path calls this per row. - mark_local_model_unavailable_if_applicable(&failure); - mark_local_model_unavailable_if_applicable(&failure); - assert!( - sink.drain().is_empty(), - "must not re-broadcast while already degraded for this cause" - ); - - // A successful embed clears the flag; the next outage is a new - // transition and must tell the clients again. - clear_semantic_recall_degraded(); - mark_local_model_unavailable_if_applicable(&failure); - assert!( - !sink.drain().is_empty(), - "a fresh outage after recovery must broadcast again" - ); - } - - /// #5398 (CodeRabbit) — concurrent embed tasks must not all decide they are - /// the first to announce. The claim is a `compare_exchange`, so exactly one - /// of N racing callers publishes. Deterministic: the assertion is on the - /// count of claims, which the atomic makes exact regardless of scheduling. - #[test] - fn concurrent_failures_announce_exactly_once() { - let _g = test_guard(); - let sink = crate::events::RecordingSink::install(); - - const THREADS: usize = 8; - std::thread::scope(|scope| { - for _ in 0..THREADS { - scope.spawn(|| { - mark_local_model_unavailable_if_applicable(&PipelineFailure::new( - FailureCode::LocalModelUnavailable, - )); - }); - } - }); - - let published = sink.drain().len(); - assert_eq!( - published, 1, - "{THREADS} concurrent failures must yield exactly one announcement" - ); - } - - /// #5398 (CodeRabbit) — `publish_web_channel_event` is an unbuffered - /// broadcast: an announcement made before any client subscribed is dropped - /// with no replay. Bounded re-emission is what covers that, so a client - /// connecting mid-outage must still be told on the next failing operation. - #[test] - fn announcement_reaches_a_client_that_connects_mid_outage() { - let _g = test_guard(); - let failure = PipelineFailure::new(FailureCode::LocalModelUnavailable); - - // Outage starts with nobody listening — this send goes nowhere. - mark_local_model_unavailable_if_applicable(&failure); - - // The client connects now, after the first failure. - let sink = crate::events::RecordingSink::install(); - assert!( - sink.drain().is_empty(), - "the pre-subscription announcement is genuinely gone, not buffered" - ); - - // Next seal / re-embed operation builds its write embedder, which - // clears the degraded state, then fails again against the same dead - // runtime. The late subscriber must receive that one. - clear_semantic_recall_degraded(); - mark_local_model_unavailable_if_applicable(&failure); - - let recorded = sink.drain(); - assert_eq!( - recorded.len(), - 1, - "a client connecting mid-outage must still be told" - ); - assert!(matches!( - recorded[0], - crate::events::MemoryEvent::LocalModelUnavailable { .. } - )); - } - - /// A different active cause must not be mistaken for "already surfaced" — - /// recall degraded for an unrelated reason still needs the local-runtime - /// entry when Ollama then goes away. - #[test] - fn local_model_unavailable_broadcasts_over_a_different_active_cause() { - let _g = test_guard(); - let sink = crate::events::RecordingSink::install(); - - mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); - mark_local_model_unavailable_if_applicable(&PipelineFailure::new( - FailureCode::LocalModelUnavailable, - )); - - assert!( - !sink.drain().is_empty(), - "a cause change into local_model_unavailable is a transition" - ); - } - - /// The helper must stay a no-op for every other cause — a cloud budget or - /// transport failure has nothing to do with the local runtime, and marking - /// recall degraded there would show the wrong remediation. - #[test] - fn other_failure_codes_do_not_mark_recall_degraded() { - let _g = test_guard(); - - for code in [ - FailureCode::Transient, - FailureCode::BudgetExhausted, - FailureCode::AuthMissing, - ] { - mark_local_model_unavailable_if_applicable(&PipelineFailure::new(code)); - assert!( - !current_degraded_state().semantic_recall, - "{} must not flip the recall flag", - code.as_str() - ); - } - } - - /// Regression (CodeRabbit): per-flag causes. Mark recall, then structure, - /// then clear structure — recall must still report its OWN cause, not the - /// (now-cleared) structure cause. With the old single shared slot this - /// surfaced the wrong remediation. - #[test] - fn degraded_cause_is_per_flag_not_shared() { - let _g = test_guard(); // resets both flags + causes - - // Recall degraded for embeddings reason; structure degraded for extraction. - mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); - mark_structure_degraded(FailureCode::ExtractionTimeout); - - // Structure takes precedence while both are active. - let s = current_degraded_state(); - assert!(s.semantic_recall && s.structure); - assert_eq!( - s.cause.as_ref().map(|c| c.code), - Some(FailureCode::ExtractionTimeout) - ); - - // Clear structure — recall stays, and its cause must be the RECALL one, - // not the cleared structure cause. - clear_structure_degraded(); - let s = current_degraded_state(); - assert!(s.semantic_recall && !s.structure); - assert_eq!( - s.cause.as_ref().map(|c| c.code), - Some(FailureCode::EmbeddingsUnconfigured), - "recall must keep its own cause after structure clears" - ); - - // Clear recall too — fully healthy, no cause. - clear_semantic_recall_degraded(); - let s = current_degraded_state(); - assert!(!s.is_degraded()); - assert!(s.cause.is_none()); - } - - /// `StorageUnavailable` is the foundational host-FS failure: unrecoverable, - /// with its own remediation key. - #[test] - fn storage_unavailable_is_unrecoverable_with_key() { - let f = PipelineFailure::new(FailureCode::StorageUnavailable); - assert_eq!(f.class, FailureClass::Unrecoverable); - assert!(f.is_unrecoverable()); - assert_eq!( - f.remediation_key, - "memory.health.remediation.storage_unavailable" - ); - // discriminant round-trips through the per-flag u8 mapping. + fn storage_unavailable_discriminant_round_trips() { assert_eq!( u8_to_code(code_to_u8(FailureCode::StorageUnavailable)), Some(FailureCode::StorageUnavailable) ); } - - /// Storage degradation outranks both structure and recall in - /// `current_degraded_state` — the host can't open the DB, so the disk fix - /// is the one actionable thing to surface. Clearing storage falls back to - /// the next-most-severe active cause (structure), each keeping its own. - #[test] - fn storage_degradation_outranks_structure_and_recall() { - let _g = test_guard(); // resets all flags + causes - - mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); - mark_structure_degraded(FailureCode::ExtractionTimeout); - mark_storage_degraded(FailureCode::StorageUnavailable); - - // All three active → storage wins. - let s = current_degraded_state(); - assert!(s.storage && s.structure && s.semantic_recall); - assert!(s.is_degraded()); - assert_eq!( - s.cause.as_ref().map(|c| c.code), - Some(FailureCode::StorageUnavailable) - ); - - // Clear storage → structure becomes the surfaced cause (its OWN, not - // storage's stale one). - clear_storage_degraded(); - let s = current_degraded_state(); - assert!(!s.storage && s.structure); - assert_eq!( - s.cause.as_ref().map(|c| c.code), - Some(FailureCode::ExtractionTimeout) - ); - } } diff --git a/crates/tinymemory-core/tests/health_globals.rs b/crates/tinymemory-core/tests/health_globals.rs new file mode 100644 index 0000000..92298d4 --- /dev/null +++ b/crates/tinymemory-core/tests/health_globals.rs @@ -0,0 +1,245 @@ +//! Isolated tests for process-global degradation flags and announcement latch. +//! +//! These assertions intentionally live in their own integration-test process. +//! The core unit-test binary runs factory and seal tests in parallel, and those +//! production paths legitimately clear the same process-global health state. + +use std::sync::{Arc, Mutex as StdMutex, MutexGuard}; + +use parking_lot::Mutex; +use tinymemory_core::events::{self, MemoryEvent, MemoryEventSink}; +use tinymemory_core::tree::health::{ + clear_semantic_recall_degraded, clear_storage_degraded, clear_structure_degraded, + current_degraded_state, mark_local_model_unavailable_if_applicable, + mark_semantic_recall_degraded, mark_storage_degraded, mark_structure_degraded, FailureClass, + FailureCode, PipelineFailure, +}; + +static HEALTH_LOCK: StdMutex<()> = StdMutex::new(()); + +fn health_guard() -> MutexGuard<'static, ()> { + let guard = HEALTH_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear_semantic_recall_degraded(); + clear_structure_degraded(); + clear_storage_degraded(); + guard +} + +#[derive(Debug, Default)] +struct RecordingSink { + events: Mutex>, +} + +impl RecordingSink { + fn drain(&self) -> Vec { + std::mem::take(&mut *self.events.lock()) + } +} + +impl MemoryEventSink for RecordingSink { + fn publish(&self, event: MemoryEvent) { + self.events.lock().push(event); + } +} + +struct SinkRestore { + previous: Option>, + installed: Arc, +} + +impl Drop for SinkRestore { + fn drop(&mut self) { + let owns_slot = events::event_sink() + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &self.installed)); + if !owns_slot { + return; + } + match self.previous.take() { + Some(previous) => events::set_event_sink(previous), + None => events::clear_event_sink(), + } + } +} + +fn install_sink() -> (Arc, SinkRestore) { + let previous = events::event_sink(); + let sink = Arc::new(RecordingSink::default()); + let installed = Arc::clone(&sink) as Arc; + events::set_event_sink(Arc::clone(&installed)); + ( + sink, + SinkRestore { + previous, + installed, + }, + ) +} + +#[test] +fn local_model_unavailable_marks_recall_degraded_with_its_cause() { + let _guard = health_guard(); + mark_local_model_unavailable_if_applicable(&PipelineFailure::new( + FailureCode::LocalModelUnavailable, + )); + + let state = current_degraded_state(); + assert!(state.semantic_recall); + assert_eq!( + state.cause.as_ref().map(|cause| cause.code), + Some(FailureCode::LocalModelUnavailable) + ); + assert_eq!( + state + .cause + .as_ref() + .map(|cause| cause.remediation_key.as_str()), + Some("memory.health.remediation.local_model_unavailable") + ); +} + +#[test] +fn local_model_unavailable_broadcasts_once_per_transition() { + let _guard = health_guard(); + let (sink, _restore) = install_sink(); + let failure = PipelineFailure::new(FailureCode::LocalModelUnavailable); + + mark_local_model_unavailable_if_applicable(&failure); + assert_eq!(sink.drain().len(), 1); + mark_local_model_unavailable_if_applicable(&failure); + mark_local_model_unavailable_if_applicable(&failure); + assert!(sink.drain().is_empty()); + + clear_semantic_recall_degraded(); + mark_local_model_unavailable_if_applicable(&failure); + assert_eq!(sink.drain().len(), 1); +} + +#[test] +fn concurrent_failures_announce_exactly_once() { + let _guard = health_guard(); + let (sink, _restore) = install_sink(); + + const THREADS: usize = 8; + std::thread::scope(|scope| { + for _ in 0..THREADS { + scope.spawn(|| { + mark_local_model_unavailable_if_applicable(&PipelineFailure::new( + FailureCode::LocalModelUnavailable, + )); + }); + } + }); + + assert_eq!( + sink.drain().len(), + 1, + "{THREADS} concurrent failures must yield exactly one announcement" + ); +} + +#[test] +fn announcement_reaches_a_client_that_connects_mid_outage() { + let _guard = health_guard(); + let failure = PipelineFailure::new(FailureCode::LocalModelUnavailable); + mark_local_model_unavailable_if_applicable(&failure); + + let (sink, _restore) = install_sink(); + assert!(sink.drain().is_empty()); + clear_semantic_recall_degraded(); + mark_local_model_unavailable_if_applicable(&failure); + + let events = sink.drain(); + assert_eq!(events.len(), 1); + assert!(matches!( + events[0], + MemoryEvent::LocalModelUnavailable { .. } + )); +} + +#[test] +fn local_model_unavailable_broadcasts_over_a_different_active_cause() { + let _guard = health_guard(); + let (sink, _restore) = install_sink(); + mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); + mark_local_model_unavailable_if_applicable(&PipelineFailure::new( + FailureCode::LocalModelUnavailable, + )); + assert_eq!(sink.drain().len(), 1); +} + +#[test] +fn other_failure_codes_do_not_mark_recall_degraded() { + let _guard = health_guard(); + for code in [ + FailureCode::Transient, + FailureCode::BudgetExhausted, + FailureCode::AuthMissing, + ] { + mark_local_model_unavailable_if_applicable(&PipelineFailure::new(code)); + assert!( + !current_degraded_state().semantic_recall, + "{} must not flip the recall flag", + code.as_str() + ); + } +} + +#[test] +fn degraded_cause_is_per_flag_not_shared() { + let _guard = health_guard(); + mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); + mark_structure_degraded(FailureCode::ExtractionTimeout); + assert_eq!( + current_degraded_state() + .cause + .as_ref() + .map(|cause| cause.code), + Some(FailureCode::ExtractionTimeout) + ); + + clear_structure_degraded(); + let state = current_degraded_state(); + assert!(state.semantic_recall && !state.structure); + assert_eq!( + state.cause.as_ref().map(|cause| cause.code), + Some(FailureCode::EmbeddingsUnconfigured) + ); +} + +#[test] +fn storage_degradation_outranks_structure_and_recall() { + let _guard = health_guard(); + mark_semantic_recall_degraded(FailureCode::EmbeddingsUnconfigured); + mark_structure_degraded(FailureCode::ExtractionTimeout); + mark_storage_degraded(FailureCode::StorageUnavailable); + + let state = current_degraded_state(); + assert!(state.storage && state.structure && state.semantic_recall); + assert_eq!( + state.cause.as_ref().map(|cause| cause.code), + Some(FailureCode::StorageUnavailable) + ); + + clear_storage_degraded(); + assert_eq!( + current_degraded_state() + .cause + .as_ref() + .map(|cause| cause.code), + Some(FailureCode::ExtractionTimeout) + ); +} + +#[test] +fn storage_unavailable_is_unrecoverable_with_a_remediation_key() { + let failure = PipelineFailure::new(FailureCode::StorageUnavailable); + assert_eq!(failure.class, FailureClass::Unrecoverable); + assert!(failure.is_unrecoverable()); + assert_eq!( + failure.remediation_key, + "memory.health.remediation.storage_unavailable" + ); +} From 680c31efa76de99261156dc791c55598d29194d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 12:24:24 +0300 Subject: [PATCH 05/45] Expand adapter and testing UI coverage Co-authored-by: Medulla --- Cargo.lock | 3 + crates/tinymemory-module/src/chat.rs | 4 + crates/tinymemory-module/src/chat_test.rs | 118 ++++ .../tinymemory-module/src/embedding_test.rs | 6 +- crates/tinymemory-module/src/host.rs | 4 + crates/tinymemory-module/src/host_test.rs | 39 ++ crates/tinymemory-module/src/service/mod.rs | 2 - crates/tinymemory-module/src/service/test.rs | 116 ++++ crates/tinymemory-module/tests/module_e2e.rs | 6 +- crates/tinymemory-remote/src/cognee_test.rs | 170 +++++ .../tinymemory-remote/src/conformance_test.rs | 7 +- crates/tinymemory-remote/src/mem0_test.rs | 114 ++++ crates/tinymemory-testing-ui/Cargo.toml | 7 + crates/tinymemory-testing-ui/src/main.rs | 44 +- crates/tinymemory-testing-ui/src/test.rs | 601 ++++++++++++++++++ .../tinymemory-tinycortex/src/engine/test.rs | 113 +++- .../tests/full_provider_conformance.rs | 359 +++++++++++ 17 files changed, 1679 insertions(+), 34 deletions(-) create mode 100644 crates/tinymemory-module/src/chat_test.rs create mode 100644 crates/tinymemory-module/src/host_test.rs create mode 100644 crates/tinymemory-testing-ui/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 9e50cf3..0014b03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2043,7 +2043,9 @@ name = "tinymemory-testing-ui" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", + "http-body-util", "serde", "serde_json", "tinymemory", @@ -2052,6 +2054,7 @@ dependencies = [ "tinymemory-remote", "tinymemory-tinycortex", "tokio", + "tower", "tower-http", ] diff --git a/crates/tinymemory-module/src/chat.rs b/crates/tinymemory-module/src/chat.rs index 3eb894f..c4057ce 100644 --- a/crates/tinymemory-module/src/chat.rs +++ b/crates/tinymemory-module/src/chat.rs @@ -112,3 +112,7 @@ impl ChatModel<()> for BusChatModel { .map_err(|error| tinyagents::TinyAgentsError::Model(error.to_string())) } } + +#[cfg(test)] +#[path = "chat_test.rs"] +mod test; diff --git a/crates/tinymemory-module/src/chat_test.rs b/crates/tinymemory-module/src/chat_test.rs new file mode 100644 index 0000000..083c1bb --- /dev/null +++ b/crates/tinymemory-module/src/chat_test.rs @@ -0,0 +1,118 @@ +//! Tests for the host-owned chat bridge over an in-memory TinyBus. + +use tinyagents::harness::message::{AssistantMessage, ContentBlock, Message}; +use tinyagents::harness::model::{ModelRequest, ModelResponse}; +use tinyagents::harness::usage::Usage; +use tinybus::broker::Broker; +use tinybus::transport::memory::MemoryBus; +use tinybus::{Connection, Result as BusResult}; + +use super::{BusChatHost, CHAT_HOST_BUS_NAME, CHAT_HOST_OBJECT_PATH}; +use crate::config::ModuleConfig; + +struct FakeChatHost; + +#[tinybus::interface(name = "ai.tinyhumans.tinymemory.ChatHost")] +impl FakeChatHost { + #[allow(clippy::unused_async, reason = "the interface macro requires async")] + async fn complete(&self, role: String, request: ModelRequest) -> BusResult { + Ok(ModelResponse { + message: AssistantMessage { + id: None, + content: vec![ContentBlock::Text(format!( + "{role}:{}", + request.messages.len() + ))], + tool_calls: Vec::new(), + usage: Some(Usage::new(2, 1)), + }, + usage: Some(Usage::new(2, 1)), + finish_reason: Some("stop".into()), + raw: None, + resolved_model: None, + continue_turn: None, + served_from_cache: false, + }) + } +} + +async fn bus_with_chat_host() -> Connection { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let _broker_task = broker.spawn(bus.clone()); + let host = Connection::connect(bus.connect().await.expect("host transport")) + .await + .expect("host connection"); + host.serve_at( + CHAT_HOST_OBJECT_PATH.try_into().expect("object path"), + FakeChatHost, + ) + .await + .expect("serve chat host"); + host.request_name(CHAT_HOST_BUS_NAME) + .await + .expect("claim name"); + std::mem::forget(host); + Connection::connect(bus.connect().await.expect("module transport")) + .await + .expect("module connection") +} + +#[tokio::test] +async fn configured_role_and_model_cross_the_chat_bridge() { + use tinymemory_core::chat_host::ChatHost; + + let config = ModuleConfig { + memory_provider: Some("host-router".into()), + default_model: Some("host-model".into()), + ..ModuleConfig::default() + }; + let bridge = BusChatHost::new(bus_with_chat_host().await, &config); + let runtime = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&config); + assert_eq!( + bridge.provider_for_role("summarizer", &runtime), + "host-router" + ); + let (model, model_id) = bridge + .create_chat_model_with_model_id("summarizer", &runtime, 0.2) + .expect("create bus model"); + assert_eq!(model_id, "host-model"); + assert_eq!( + model.cache_identity().as_deref(), + Some("tinymemory-module-host:summarizer") + ); + let response = model + .invoke(&(), ModelRequest::new(vec![Message::user("summarize")])) + .await + .expect("chat call"); + assert!(matches!( + response.message.content.as_slice(), + [ContentBlock::Text(text)] if text == "summarizer:1" + )); +} + +#[tokio::test] +async fn defaults_are_credential_free_and_an_absent_host_fails_cleanly() { + use tinymemory_core::chat_host::ChatHost; + + let bus = MemoryBus::new(); + let broker = Broker::new(); + let _broker_task = broker.spawn(bus.clone()); + let connection = Connection::connect(bus.connect().await.expect("transport")) + .await + .expect("connection"); + let bridge = BusChatHost::new(connection, &ModuleConfig::default()); + let runtime = + tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); + assert_eq!(bridge.provider_for_role("role", &runtime), "host"); + let (model, model_id) = bridge + .create_chat_model_with_model_id("role", &runtime, 0.0) + .expect("create model"); + assert_eq!(model_id, "host-default"); + assert!(model + .invoke(&(), ModelRequest::default()) + .await + .expect_err("no host name is served") + .to_string() + .contains(CHAT_HOST_BUS_NAME)); +} diff --git a/crates/tinymemory-module/src/embedding_test.rs b/crates/tinymemory-module/src/embedding_test.rs index f90ada3..3236a8f 100644 --- a/crates/tinymemory-module/src/embedding_test.rs +++ b/crates/tinymemory-module/src/embedding_test.rs @@ -27,11 +27,7 @@ struct FakeHostEmbedder { #[tinybus::interface(name = "ai.tinyhumans.tinymemory.EmbeddingHost")] impl FakeHostEmbedder { - #[allow( - clippy::unused_async, - clippy::unused_async_trait_impl, - reason = "the interface macro requires async" - )] + #[allow(clippy::unused_async, reason = "the interface macro requires async")] async fn embed( &self, _model: String, diff --git a/crates/tinymemory-module/src/host.rs b/crates/tinymemory-module/src/host.rs index 8147ce7..b46674a 100644 --- a/crates/tinymemory-module/src/host.rs +++ b/crates/tinymemory-module/src/host.rs @@ -123,3 +123,7 @@ pub(crate) fn install(connection: Connection) { tinymemory_core::observability::set_error_reporter(Arc::clone(&host) as Arc); tinymemory_core::nlp_host::set_nlp_host(host); } + +#[cfg(test)] +#[path = "host_test.rs"] +mod test; diff --git a/crates/tinymemory-module/src/host_test.rs b/crates/tinymemory-module/src/host_test.rs new file mode 100644 index 0000000..87800b6 --- /dev/null +++ b/crates/tinymemory-module/src/host_test.rs @@ -0,0 +1,39 @@ +//! Tests for runtime-host callback argument ownership and safe diagnostics. + +#[test] +fn callback_tags_are_owned_without_changing_order_or_values() { + let key = String::from("source"); + let value = String::from("sync"); + let owned = super::owned_tags(&[(&key, &value), ("attempt", "2")]); + drop(key); + drop(value); + assert_eq!( + owned, + vec![ + ("source".to_string(), "sync".to_string()), + ("attempt".to_string(), "2".to_string()) + ] + ); +} + +#[tokio::test] +async fn absent_runtime_host_returns_an_error_instead_of_hanging() { + use tinybus::transport::memory::MemoryBus; + use tinymemory_core::nlp_host::NlpHost; + + let bus = MemoryBus::new(); + let broker = tinybus::broker::Broker::new(); + let _broker_task = broker.spawn(bus.clone()); + let connection = tinybus::Connection::connect(bus.connect().await.expect("transport")) + .await + .expect("connection"); + let host = super::BusRuntimeHost::new(connection); + let config = crate::config::ModuleConfig::default(); + let runtime = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&config); + let error = host + .extract_spacy(&runtime, "text") + .await + .expect_err("no runtime host is served"); + assert!(error.contains(super::RUNTIME_HOST_BUS_NAME), "{error}"); + assert!(!format!("{host:?}").contains("Connection")); +} diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index afee3d8..eba50ab 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -256,7 +256,6 @@ impl MemoryService { /// The bound driver's stable identifier. #[allow( clippy::unused_async, - clippy::unused_async_trait_impl, reason = "tinybus::interface requires every method to be `async fn`" )] async fn driver_id(&self) -> BusResult { @@ -270,7 +269,6 @@ impl MemoryService { /// change afterwards. #[allow( clippy::unused_async, - clippy::unused_async_trait_impl, reason = "tinybus::interface requires every method to be `async fn`" )] async fn capabilities(&self) -> BusResult { diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index e1761ca..80befc0 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -18,6 +18,23 @@ use tinymemory_api::wire; use super::into_bus_error; +fn test_provider() -> std::sync::Arc { + std::sync::Arc::new(tinymemory_tinycortex::provider(std::sync::Arc::new( + tinycortex::memory::store::InMemoryMemoryStore::new(), + ))) +} + +async fn test_connection() -> tinybus::Connection { + use tinybus::transport::memory::MemoryBus; + + let bus = MemoryBus::new(); + let broker = tinybus::broker::Broker::new(); + let _broker_task = broker.spawn(bus.clone()); + tinybus::Connection::connect(bus.connect().await.expect("test transport")) + .await + .expect("test connection") +} + /// The name and message a mapped error carries on the wire. fn mapped(error: &MemoryError) -> (String, String) { match into_bus_error(error) { @@ -224,6 +241,105 @@ fn the_per_entry_overhead_is_counted_so_many_tiny_entries_still_trip_it() { ); } +#[test] +fn store_object_paths_accept_only_one_safe_identifier_component() { + let valid = ["profile-1", "profile_one", "A9", &"x".repeat(128)]; + for subdir in valid { + assert_eq!( + super::object_path_for_subdir(subdir), + Some(format!("{}/stores/{subdir}", super::OBJECT_PATH)) + ); + } + + for invalid in [ + "", + ".", + "..", + "../escape", + "nested/store", + "nested\\store", + "profile.name", + "profile name", + "pröfile", + &"x".repeat(129), + ] { + assert!( + super::object_path_for_subdir(invalid).is_none(), + "unsafe subdirectory was admitted: {invalid:?}" + ); + } +} + +#[tokio::test] +async fn a_leaf_store_cannot_recursively_open_another_store() { + let service = super::MemoryService::new(test_provider()); + let error = service + .open_store("child".to_string()) + .await + .expect_err("leaf stores must not recursively open stores"); + let tinybus::Error::MethodFailed { name, message } = error else { + panic!("expected MethodFailed"); + }; + assert_eq!(name, tinymemory_api::wire::INVALID); + assert!(message.contains("root")); +} + +#[tokio::test] +async fn repeated_and_concurrent_opens_reuse_the_registered_object_path() { + use std::sync::Arc; + + let connection = test_connection().await; + let opener = Arc::new(super::StoreOpener::new( + connection, + crate::config::ModuleConfig::default(), + )); + let expected = format!("{}/stores/profile-1", super::OBJECT_PATH); + opener + .served + .lock() + .await + .insert("profile-1".to_string(), expected.clone()); + let service = Arc::new(super::MemoryService::root(test_provider(), opener)); + + let mut tasks = Vec::new(); + for _ in 0..16 { + let service = Arc::clone(&service); + tasks.push(tokio::spawn(async move { + service.open_store("profile-1".to_string()).await + })); + } + for task in tasks { + assert_eq!(task.await.expect("join").expect("reused store"), expected); + } +} + +#[tokio::test] +async fn the_open_store_cap_is_enforced_before_allocating_another_store() { + use std::sync::Arc; + + let opener = Arc::new(super::StoreOpener::new( + test_connection().await, + crate::config::ModuleConfig::default(), + )); + { + let mut served = opener.served.lock().await; + for index in 0..super::MAX_OPEN_STORES { + served.insert(format!("profile-{index}"), format!("/served/{index}")); + } + } + let service = super::MemoryService::root(test_provider(), Arc::clone(&opener)); + let error = service + .open_store("one-more".to_string()) + .await + .expect_err("the store cap must be enforced"); + let tinybus::Error::MethodFailed { name, message } = error else { + panic!("expected MethodFailed"); + }; + assert_eq!(name, tinymemory_api::wire::INVALID); + assert!(message.contains(&super::MAX_OPEN_STORES.to_string())); + assert_eq!(opener.served.lock().await.len(), super::MAX_OPEN_STORES); +} + /// Every method the service implements must also be declared in the manifest. /// /// The manifest's `methods` list is admission surface: the host may only call a diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 49af10e..7c24f77 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -69,11 +69,7 @@ struct HostEmbedder; #[tinybus::interface(name = "ai.tinyhumans.tinymemory.EmbeddingHost")] impl HostEmbedder { - #[allow( - clippy::unused_async, - clippy::unused_async_trait_impl, - reason = "the interface macro requires async" - )] + #[allow(clippy::unused_async, reason = "the interface macro requires async")] async fn embed( &self, _model: String, diff --git a/crates/tinymemory-remote/src/cognee_test.rs b/crates/tinymemory-remote/src/cognee_test.rs index 73584e4..0d6f98f 100644 --- a/crates/tinymemory-remote/src/cognee_test.rs +++ b/crates/tinymemory-remote/src/cognee_test.rs @@ -13,6 +13,7 @@ use axum::{ }; use serde_json::{json, Value}; use tinymemory_api::{ + capabilities::Capability, provider::{MemoryCore, MemoryGraph, MemoryProvider, MemoryRecall}, recall::OwnedRecallOpts, traits::Memory, @@ -201,6 +202,175 @@ async fn cognee_graph_supports_cloud_api_keys_and_self_hosted_bearer_tokens() { assert!(crate::CogneeGraph::api(&endpoint, " ").is_err()); } +#[tokio::test] +async fn cognee_graph_maps_filters_and_limits_native_edges() { + let graph_calls = Arc::new(Mutex::new(0_usize)); + let calls = graph_calls.clone(); + let app = Router::new() + .route( + "/api/v1/datasets/", + get(|| async { + Json(json!([{ + "id": "dataset-1", + "name": super::CogneeDialect::dataset_name("project") + }])) + }), + ) + .route( + "/api/v1/datasets/dataset-1/graph", + get(move || { + let calls = calls.clone(); + async move { + *calls.lock().expect("calls") += 1; + Json(json!({ + "nodes": [ + {"id": "alice", "label": "Alice"}, + {"id": "bob", "label": "Bob"}, + {"id": "carol", "label": "Carol"} + ], + "edges": [ + {"source": "alice", "target": "bob", "label": "knows"}, + {"source": "alice", "target": "carol", "label": "manages"}, + {"source": "unknown", "target": "bob", "label": null}, + {"source": 12, "target": "bob", "label": "malformed"} + ] + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let graph = crate::CogneeGraph::new(&endpoint, None).expect("client"); + let relations = graph + .relations(Some("project"), Some("Alice"), None, 1) + .await + .expect("filtered relations"); + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].subject, "Alice"); + assert_eq!(relations[0].predicate, "knows"); + assert_eq!(relations[0].object, "Bob"); + assert_eq!(relations[0].namespace.as_deref(), Some("project")); + + let fallback = graph + .relations(Some("project"), Some("unknown"), Some(""), 10) + .await + .expect("id fallback"); + assert_eq!(fallback.len(), 1); + assert_eq!(fallback[0].object, "Bob"); + assert_eq!(*graph_calls.lock().expect("calls"), 2); +} + +#[tokio::test] +async fn cognee_graph_rejects_unscoped_queries_and_unsupported_mutations() { + let app = Router::new().route("/api/v1/datasets/", get(|| async { Json(json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + let graph = crate::CogneeGraph::new(&endpoint, None).expect("client"); + + let error = graph + .relations(None, None, None, 10) + .await + .expect_err("namespace is required"); + assert!(matches!( + error, + tinymemory_api::error::MemoryError::Invalid(_) + )); + assert!(graph + .relations(Some("missing"), None, None, 10) + .await + .expect("missing dataset") + .is_empty()); + + let relation = tinymemory_api::types::GraphRelationRecord { + namespace: Some("project".into()), + subject: "Alice".into(), + predicate: "knows".into(), + object: "Bob".into(), + attrs: Value::Null, + updated_at: 0.0, + evidence_count: 1, + order_index: None, + document_ids: Vec::new(), + chunk_ids: Vec::new(), + }; + let errors = [ + graph.kv_get(Some("project"), "key").await.err(), + graph.kv_put(Some("project"), "key", json!(1)).await.err(), + graph.kv_delete(Some("project"), "key").await.err(), + graph.kv_list(Some("project"), None, 10).await.err(), + graph.put_relation(relation).await.err(), + ]; + assert!(errors.iter().all(Option::is_some)); + assert!(format!("{:#}", errors[0].as_ref().expect("kv error")) + .contains("no generic key/value store")); + assert!(format!("{:#}", errors[4].as_ref().expect("relation error")) + .contains("cannot be edited directly")); +} + +#[tokio::test] +async fn cognee_graph_provider_advertises_an_auditable_graph() { + let app = Router::new().route("/api/v1/datasets/", get(|| async { Json(json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let memory = super::CogneeMemory::self_hosted(&endpoint, None).expect("memory client"); + let provider = crate::cognee_graph_provider(memory, &endpoint, None).expect("graph provider"); + tinymemory_api::provider::audit_provider(&provider).expect("honest graph capability"); + assert_eq!(provider.driver_id(), crate::COGNEE_DRIVER_ID); + assert!(provider.capabilities().contains(Capability::Graph)); + assert!(provider.as_graph().is_some()); +} + +#[tokio::test] +async fn cognee_graph_surfaces_http_failures_without_parsing_them_as_empty() { + let app = Router::new() + .route( + "/api/v1/datasets/", + get(|| async { + Json(json!([{ + "id": "dataset-1", + "name": super::CogneeDialect::dataset_name("project") + }])) + }), + ) + .route( + "/api/v1/datasets/dataset-1/graph", + get(|| async { (StatusCode::BAD_REQUEST, "graph unavailable") }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let error = crate::CogneeGraph::new(&endpoint, None) + .expect("client") + .relations(Some("project"), None, None, 10) + .await + .expect_err("HTTP failure must propagate"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("HTTP 400"), "{rendered}"); + assert!(rendered.contains("graph unavailable"), "{rendered}"); +} + #[test] fn cognee_remote_names_are_bounded_and_safe_for_arbitrary_contract_keys() { let unusual = format!("tenant / 🧠 / {}", "x".repeat(500)); diff --git a/crates/tinymemory-remote/src/conformance_test.rs b/crates/tinymemory-remote/src/conformance_test.rs index 4f3df53..019ea80 100644 --- a/crates/tinymemory-remote/src/conformance_test.rs +++ b/crates/tinymemory-remote/src/conformance_test.rs @@ -492,7 +492,12 @@ async fn cg_recall(State(sets): State, Json(body): Json) -> Jso .take(limit) .map(|raw| json!({ "text": raw })) .collect(); - Json(json!({ "results": hits })) + // Cognee's `only_context` recall response is the result array itself. The + // adapter deliberately decodes that native shape (the focused Cognee + // contract double does too); wrapping it in `{ "results": ... }` makes a + // healthy adapter appear to return no rows and lets this conformance test + // fail for a bug in its own fake backend. + Json(Value::Array(hits)) } async fn cognee_backend() -> String { diff --git a/crates/tinymemory-remote/src/mem0_test.rs b/crates/tinymemory-remote/src/mem0_test.rs index 29f4604..9b5d1c8 100644 --- a/crates/tinymemory-remote/src/mem0_test.rs +++ b/crates/tinymemory-remote/src/mem0_test.rs @@ -12,6 +12,7 @@ use axum::{ }; use serde_json::{json, Value}; use tinymemory_api::{ + capabilities::Capability, provider::{MemoryCore, MemoryProvider, MemoryRecall}, recall::OwnedRecallOpts, types::{MemoryCategory, MemoryTaint}, @@ -165,6 +166,119 @@ async fn native_mem0_round_trips_the_tinymemory_contract() { assert!(driver.health().await.is_usable()); } +#[tokio::test] +async fn mem0_graph_filters_limits_and_exposes_only_supported_operations() { + let state = AppState::default(); + let app = Router::new() + .route("/memories", get(list).post(add)) + .route("/memories/{id}", put(update).delete(remove)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let provider = crate::mem0_graph_provider( + super::Mem0Memory::self_hosted(&endpoint, None).expect("client"), + ); + tinymemory_api::provider::audit_provider(&provider).expect("honest graph capability"); + assert_eq!(provider.driver_id(), crate::MEM0_DRIVER_ID); + assert!(provider.capabilities().contains(Capability::Graph)); + + provider + .store( + "team", + "first", + "Alice met Bob. Alice introduced Carol.", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("delegated store"); + provider + .store( + "other", + "second", + "Alice met Mallory.", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("other namespace store"); + + let graph = provider.as_graph().expect("advertised graph"); + let relations = graph + .relations(Some("team"), Some("Alice"), Some("co_occurs_with"), 1) + .await + .expect("relations"); + assert_eq!(relations.len(), 1, "the caller's limit is a hard cap"); + assert_eq!(relations[0].subject, "Alice"); + assert_eq!(relations[0].object, "Bob"); + assert_eq!(relations[0].namespace.as_deref(), Some("team")); + assert_eq!(relations[0].document_ids, ["mem-1"]); + assert_eq!(relations[0].attrs["source"], "heuristic"); + assert!(graph + .relations(Some("team"), None, Some("does_not_exist"), 10) + .await + .expect("predicate filter") + .is_empty()); + assert!(graph + .relations(Some("team"), None, None, 0) + .await + .expect("zero limit") + .is_empty()); + + let relation = relations[0].clone(); + let errors = [ + graph.kv_get(Some("team"), "key").await.err(), + graph + .kv_put(Some("team"), "key", json!({"value": 1})) + .await + .err(), + graph.kv_delete(Some("team"), "key").await.err(), + graph.kv_list(Some("team"), None, 10).await.err(), + graph.put_relation(relation).await.err(), + ]; + assert!(errors.iter().all(Option::is_some)); + assert!(format!("{:#}", errors[0].as_ref().expect("kv error")) + .contains("no generic key/value store")); + assert!(format!("{:#}", errors[4].as_ref().expect("relation error")) + .contains("cannot be edited directly")); +} + +#[tokio::test] +async fn mem0_graph_propagates_listing_failures() { + let app = Router::new().route( + "/memories", + get(|| async { (StatusCode::BAD_REQUEST, "invalid namespace") }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let provider = crate::mem0_graph_provider( + super::Mem0Memory::self_hosted(&endpoint, None).expect("client"), + ); + let error = provider + .as_graph() + .expect("graph") + .relations(Some("team"), None, None, 10) + .await + .expect_err("listing failure must propagate"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("HTTP 400"), "{rendered}"); + assert!(rendered.contains("invalid namespace"), "{rendered}"); +} + /// Issue #69: a self-hosted keyed read scopes the listing to the namespace's /// `user_id` — percent-encoded, since namespaces carry slashes — instead of /// walking the whole store. diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml index 8bffcd9..f136fc3 100644 --- a/crates/tinymemory-testing-ui/Cargo.toml +++ b/crates/tinymemory-testing-ui/Cargo.toml @@ -35,3 +35,10 @@ tower-http = { version = "0.6", features = ["fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" + +[dev-dependencies] +# HTTP-level tests exercise the Axum router without binding a port. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +# Test providers implement the same async driver contracts as real adapters. +async-trait = "0.1" diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs index 5601cf4..42e08d2 100644 --- a/crates/tinymemory-testing-ui/src/main.rs +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -467,15 +467,12 @@ async fn document_formats(State(state): State) -> Result Some( - DocumentIntake::new(provider.as_ref(), &chain) - .route() - .as_str() - .to_string(), - ), - None => None, - }; + let route = state.active.read().await.as_ref().map(|provider| { + DocumentIntake::new(provider.as_ref(), &chain) + .route() + .as_str() + .to_string() + }); Ok(Json(serde_json::json!({ "formats": formats, "route": route })).into_response()) } @@ -634,15 +631,7 @@ fn intake_request( Ok(request) } -#[tokio::main] -async fn main() { - let state: SharedState = Arc::new(AppState { - active: RwLock::new(None), - }); - - let web_dir = std::env::var("TINYMEMORY_TESTING_UI_WEB") - .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/web").to_string()); - +fn app(state: SharedState, web_dir: impl Into) -> Router { let api = Router::new() .route("/connect", post(connect)) .route("/disconnect", post(disconnect)) @@ -661,9 +650,21 @@ async fn main() { .route("/ingest/url", post(ingest_url)) .with_state(state); - let app = Router::new() + Router::new() .nest("/api", api) - .fallback_service(ServeDir::new(web_dir)); + .fallback_service(ServeDir::new(web_dir.into())) +} + +#[tokio::main] +async fn main() { + let state: SharedState = Arc::new(AppState { + active: RwLock::new(None), + }); + + let web_dir = std::env::var("TINYMEMORY_TESTING_UI_WEB") + .unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/web").to_string()); + + let app = app(state, web_dir); let addr: SocketAddr = std::env::var("TINYMEMORY_TESTING_UI_ADDR") .ok() @@ -676,3 +677,6 @@ async fn main() { .expect("bind testing UI address"); axum::serve(listener, app).await.expect("serve testing UI"); } + +#[cfg(test)] +mod test; diff --git a/crates/tinymemory-testing-ui/src/test.rs b/crates/tinymemory-testing-ui/src/test.rs new file mode 100644 index 0000000..627572b --- /dev/null +++ b/crates/tinymemory-testing-ui/src/test.rs @@ -0,0 +1,601 @@ +//! HTTP and static UI contract tests for the local testing harness. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; + +use tinymemory_api::capabilities::{Capabilities, Capability}; +use tinymemory_api::error::MemoryError; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use tinymemory_api::provider::{ + MemoryCore, MemoryDocuments, MemoryPortability, MemoryProvider, MemoryRecall, +}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{ + MemoryCategory, MemoryEntry, MemoryTaint, NamespaceDocumentInput, NamespaceRetrievalContext, + NamespaceSummary, StoredMemoryDocument, +}; + +use super::*; + +fn empty_state() -> SharedState { + Arc::new(AppState { + active: RwLock::new(None), + }) +} + +fn test_app(state: SharedState) -> Router { + app(state, concat!(env!("CARGO_MANIFEST_DIR"), "/web")) +} + +fn json_request(method: Method, uri: &str, value: Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(value.to_string())) + .unwrap() +} + +async fn json_body(response: Response) -> Value { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + } +} + +async fn connect_local(router: &Router) { + let response = router + .clone() + .oneshot(json_request( + Method::POST, + "/api/connect", + json!({ "engine": "local" }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn operations_require_a_connected_engine() { + let response = test_app(empty_state()) + .oneshot(json_request( + Method::POST, + "/api/store", + json!({ "namespace": "notes", "key": "one", "content": "body" }), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + json_body(response).await, + json!({ "error": "no engine connected yet" }) + ); +} + +#[tokio::test] +async fn local_connect_status_and_disconnect_are_consistent() { + let router = test_app(empty_state()); + connect_local(&router).await; + + let status = router + .clone() + .oneshot(Request::get("/api/status").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = json_body(status).await; + assert_eq!(body["connected"], true); + assert_eq!(body["driver_id"], "tinycortex"); + + let disconnected = router + .clone() + .oneshot( + Request::post("/api/disconnect") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + json_body(disconnected).await, + json!({ + "connected": false, + "driver_id": null, + "engine": null, + "has_graph": false + }) + ); +} + +#[tokio::test] +async fn local_engine_supports_the_complete_core_http_workflow() { + let router = test_app(empty_state()); + connect_local(&router).await; + + let stored = router + .clone() + .oneshot(json_request( + Method::POST, + "/api/store", + json!({ + "namespace": "notes", + "key": "theme", + "content": "prefers dark mode", + "category": "daily", + "session_id": "session-1", + "taint": "external_sync" + }), + )) + .await + .unwrap(); + assert_eq!(stored.status(), StatusCode::NO_CONTENT); + + let entry = router + .clone() + .oneshot( + Request::get("/api/get?namespace=notes&key=theme") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let entry = json_body(entry).await; + assert_eq!(entry["content"], "prefers dark mode"); + assert_eq!(entry["category"], "daily"); + assert_eq!(entry["session_id"], "session-1"); + assert_eq!(entry["taint"], "external_sync"); + + let listed = router + .clone() + .oneshot( + Request::get("/api/list?namespace=notes&category=daily&session_id=session-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(json_body(listed).await.as_array().unwrap().len(), 1); + + let recalled = router + .clone() + .oneshot(json_request( + Method::POST, + "/api/recall", + json!({ "query": "dark mode", "namespace": "notes", "limit": 10 }), + )) + .await + .unwrap(); + assert_eq!(json_body(recalled).await[0]["key"], "theme"); + + let exported = router + .clone() + .oneshot( + Request::get("/api/export?limit=10") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + json_body(exported).await["records"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let forgotten = router + .clone() + .oneshot(json_request( + Method::POST, + "/api/forget", + json!({ "namespace": "notes", "key": "theme" }), + )) + .await + .unwrap(); + assert_eq!(json_body(forgotten).await, json!(true)); +} + +#[tokio::test] +async fn invalid_engine_deployment_and_cloud_credentials_are_rejected() { + let cases = [ + (json!({ "engine": "unknown" }), "unknown engine: unknown"), + ( + json!({ "engine": "supermemory" }), + "supermemory requires an endpoint URL", + ), + ( + json!({ "engine": "mem0", "endpoint": "http://localhost", "deployment": "other" }), + "unknown Mem0 deployment: other", + ), + ( + json!({ "engine": "mem0", "endpoint": "https://api.mem0.ai", "deployment": "cloud" }), + "Mem0 Cloud requires an API key", + ), + ( + json!({ "engine": "cognee", "endpoint": "https://example.invalid", "deployment": "cloud" }), + "Cognee Cloud requires an API key", + ), + ]; + + for (request, message) in cases { + let response = test_app(empty_state()) + .oneshot(json_request(Method::POST, "/api/connect", request)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"], message); + } +} + +#[tokio::test] +async fn memory_errors_have_stable_http_statuses_and_json_bodies() { + let cases = [ + (MemoryError::Invalid("bad".into()), StatusCode::BAD_REQUEST), + ( + MemoryError::PathEscape("bad".into()), + StatusCode::BAD_REQUEST, + ), + (MemoryError::NotFound("gone".into()), StatusCode::NOT_FOUND), + ( + MemoryError::BudgetExceeded("large".into()), + StatusCode::PAYLOAD_TOO_LARGE, + ), + ( + MemoryError::Unauthorized("key".into()), + StatusCode::UNAUTHORIZED, + ), + ( + MemoryError::Timeout("slow".into()), + StatusCode::GATEWAY_TIMEOUT, + ), + ( + MemoryError::Unavailable("busy".into()), + StatusCode::SERVICE_UNAVAILABLE, + ), + (MemoryError::Backend("bad".into()), StatusCode::BAD_GATEWAY), + ]; + + for (error, expected) in cases { + let expected_message = error.to_string(); + let response = ApiError::from(error).into_response(); + assert_eq!(response.status(), expected); + assert_eq!(json_body(response).await["error"], expected_message); + } +} + +#[tokio::test] +async fn document_formats_report_conversion_and_connection_route() { + let router = test_app(empty_state()); + let disconnected = router + .clone() + .oneshot( + Request::get("/api/documents/formats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let disconnected = json_body(disconnected).await; + assert_eq!(disconnected["route"], Value::Null); + assert_eq!( + disconnected["formats"], + json!(["markdown", "plain_text", "html"]) + ); + + connect_local(&router).await; + let connected = router + .oneshot( + Request::get("/api/documents/formats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(json_body(connected).await["route"].is_string()); +} + +type MultipartPart<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a [u8]); + +fn multipart_request(parts: &[MultipartPart<'_>]) -> Request { + let boundary = "tinymemory-test-boundary"; + let mut body = Vec::new(); + for (name, filename, content_type, value) in parts { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes(), + ); + if let Some(filename) = filename { + body.extend_from_slice(format!("; filename=\"{filename}\"").as_bytes()); + } + body.extend_from_slice(b"\r\n"); + if let Some(content_type) = content_type { + body.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes()); + } + body.extend_from_slice(b"\r\n"); + body.extend_from_slice(value); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + Request::builder() + .method(Method::POST) + .uri("/api/documents/upload") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap() +} + +#[tokio::test] +async fn document_upload_validates_required_parts_and_supported_formats() { + let router = test_app(empty_state()); + connect_local(&router).await; + + let no_file = router + .clone() + .oneshot(multipart_request(&[( + "namespace", + None, + None, + b"documents", + )])) + .await + .unwrap(); + assert_eq!(no_file.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(no_file).await["error"], + "no `file` part in the upload" + ); + + let no_namespace = router + .clone() + .oneshot(multipart_request(&[( + "file", + Some("note.txt"), + Some("text/plain"), + b"hello", + )])) + .await + .unwrap(); + assert_eq!(no_namespace.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(no_namespace).await["error"], + "no `namespace` part in the upload" + ); + + let unsupported = router + .oneshot(multipart_request(&[ + ("namespace", None, None, b"documents"), + ("file", Some("note.pdf"), Some("application/pdf"), b"%PDF"), + ])) + .await + .unwrap(); + assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST); +} + +#[derive(Default)] +struct RecordingProvider { + document: Mutex>, +} + +#[async_trait] +impl MemoryCore for RecordingProvider { + async fn store( + &self, + _namespace: &str, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + _taint: MemoryTaint, + ) -> Result<(), MemoryError> { + Ok(()) + } + + async fn get(&self, _namespace: &str, _key: &str) -> Result, MemoryError> { + Ok(None) + } + + async fn forget(&self, _namespace: &str, _key: &str) -> Result { + Ok(false) + } + + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + + async fn namespaces(&self) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl MemoryRecall for RecordingProvider { + async fn recall( + &self, + _query: &str, + _limit: usize, + _opts: &OwnedRecallOpts, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl MemoryPortability for RecordingProvider { + async fn export_page( + &self, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(ExportPage::default()) + } + + async fn import_records( + &self, + _records: Vec, + ) -> Result { + Ok(ImportOutcome::default()) + } +} + +#[async_trait] +impl MemoryDocuments for RecordingProvider { + async fn put_document(&self, input: NamespaceDocumentInput) -> Result { + *self.document.lock().unwrap() = Some(input); + Ok("document-1".to_string()) + } + + async fn get_document( + &self, + _namespace: &str, + _key: &str, + ) -> Result, MemoryError> { + Ok(None) + } + + async fn list_documents(&self, _namespace: Option<&str>) -> Result { + Ok(Value::Null) + } + + async fn list_namespaces(&self) -> Result, MemoryError> { + Ok(Vec::new()) + } + + async fn delete_document( + &self, + _namespace: &str, + _document_id: &str, + ) -> Result { + Ok(Value::Null) + } + + async fn clear_namespace(&self, _namespace: &str) -> Result<(), MemoryError> { + Ok(()) + } + + async fn query_documents( + &self, + namespace: &str, + _query: &str, + _limit: usize, + ) -> Result { + Ok(NamespaceRetrievalContext { + namespace: namespace.to_string(), + query: None, + context_text: String::new(), + hits: Vec::new(), + }) + } +} + +#[async_trait] +impl MemoryProvider for RecordingProvider { + fn driver_id(&self) -> &str { + "recording" + } + + fn capabilities(&self) -> Capabilities { + Capabilities::mandatory().with(Capability::Documents) + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + fn as_documents(&self) -> Option<&dyn MemoryDocuments> { + Some(self) + } +} + +#[tokio::test] +async fn text_upload_preserves_filename_tags_category_and_taint() { + let provider = Arc::new(RecordingProvider::default()); + let state = empty_state(); + *state.active.write().await = Some(provider.clone()); + + let response = test_app(state) + .oneshot(multipart_request(&[ + ("namespace", None, None, b"document:manual"), + ("key", None, None, b"readme"), + ("tags", None, None, b"guide, important"), + ("category", None, None, b"custom:manual"), + ("taint", None, None, b"external_sync"), + ( + "file", + Some("README.txt"), + Some("text/plain"), + b"TinyMemory manual", + ), + ])) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["route"], "documents"); + + let recorded = provider.document.lock().unwrap(); + let document = recorded.as_ref().unwrap(); + assert_eq!(document.namespace, "document:manual"); + assert_eq!(document.key, "readme"); + assert_eq!(document.content, "TinyMemory manual"); + assert_eq!(document.tags, ["guide", "important"]); + assert_eq!(document.category, "custom:manual"); + assert_eq!(document.taint, MemoryTaint::ExternalSync); + assert_eq!(document.metadata["filename"], "README.txt"); + assert_eq!(document.metadata["source_format"], "plain_text"); +} + +#[test] +fn html_exposes_every_visible_operation_and_its_route_contract() { + let html = include_str!("../web/index.html"); + for id in [ + "connect-btn", + "disconnect-btn", + "store-btn", + "upload-btn", + "get-btn", + "recall-btn", + "list-btn", + "namespaces-btn", + "forget-btn", + "export-btn", + "graph-btn", + ] { + assert!(html.contains(&format!("id=\"{id}\"")), "missing #{id}"); + } + for route in [ + "/connect", + "/disconnect", + "/store", + "/get", + "/recall", + "/list", + "/namespaces", + "/forget", + "/export", + "/graph/relations", + ] { + assert!( + html.contains(&format!("\"{route}")), + "missing route {route}" + ); + } +} diff --git a/crates/tinymemory-tinycortex/src/engine/test.rs b/crates/tinymemory-tinycortex/src/engine/test.rs index a53d9b4..937db25 100644 --- a/crates/tinymemory-tinycortex/src/engine/test.rs +++ b/crates/tinymemory-tinycortex/src/engine/test.rs @@ -16,8 +16,46 @@ #![allow(clippy::expect_used, clippy::panic)] use tinymemory_api::capabilities::{Capabilities, Capability}; +use tinymemory_api::chunks::DataSource; +use tinymemory_api::error::MemoryError; +use tinymemory_api::host::MemoryHostConfig; +use tinymemory_api::provider::types::IngestItem; +use tinymemory_api::types::MemoryTaint; -use super::advertised_capabilities; +use super::{advertised_capabilities, validate_ingest_item, EngineRuntimeConfig}; + +fn ingest_item(content: &str, mime: Option<&str>, taint: MemoryTaint) -> IngestItem { + IngestItem { + namespace: None, + source: DataSource::Upload, + source_id: "doc-1".to_string(), + owner: "owner".to_string(), + source_ref: None, + content: content.to_string(), + mime: mime.map(str::to_string), + timestamp: None, + tags: Vec::new(), + taint, + path_scope: None, + } +} + +fn runtime_config() -> EngineRuntimeConfig { + EngineRuntimeConfig { + workspace_dir: "/workspace".into(), + config_path: "/workspace/config.toml".into(), + memory: Default::default(), + memory_tree: Default::default(), + scheduler_gate: Default::default(), + local_ai: Default::default(), + embeddings_provider: Some("ollama: nomic-embed-text".to_string()), + memory_provider: Some("ollama: qwen3".to_string()), + default_model: Some("host/model".to_string()), + default_temperature: 0.3, + output_language: Some("en".to_string()), + memory_sources: serde_json::json!([{"id": "source-1"}]), + } +} #[test] fn the_mandatory_families_are_always_advertised() { @@ -52,3 +90,76 @@ fn diff_is_withheld_when_the_snapshot_store_is_compiled_out() { assert_eq!(caps, Capabilities::all().without(Capability::Diff)); assert_eq!(caps.len(), Capabilities::all().len() - 1); } + +#[test] +fn ingest_accepts_decoded_text_mime_families() { + for mime in [ + None, + Some("text/plain"), + Some("text/markdown; charset=utf-8"), + Some("application/json"), + Some("application/activity+json"), + Some("application/xml"), + Some("application/atom+xml"), + Some("application/x-ndjson"), + ] { + validate_ingest_item(&ingest_item("decoded text", mime, MemoryTaint::Internal)) + .unwrap_or_else(|error| panic!("{mime:?} should be accepted: {error}")); + } +} + +#[test] +fn ingest_rejects_empty_binary_and_non_default_taint() { + let cases = [ + ingest_item(" ", Some("text/plain"), MemoryTaint::Internal), + ingest_item( + "binary already decoded badly", + Some("application/pdf"), + MemoryTaint::Internal, + ), + ingest_item( + "external content", + Some("text/plain"), + MemoryTaint::ExternalSync, + ), + ]; + for item in cases { + assert!( + matches!(validate_ingest_item(&item), Err(MemoryError::Invalid(_))), + "invalid ingest item was accepted: {item:?}" + ); + } +} + +#[tokio::test] +async fn runtime_config_routes_models_and_round_trips_source_configuration() { + let mut config = runtime_config(); + assert_eq!( + config.workload_local_model("embeddings").as_deref(), + Some("nomic-embed-text") + ); + assert_eq!( + config.workload_local_model("memory").as_deref(), + Some("qwen3") + ); + assert_eq!(config.workload_local_model("chat"), None); + assert_eq!(config.default_model(), Some("host/model")); + assert_eq!(config.default_temperature(), 0.3); + assert_eq!(config.output_language(), Some("en")); + assert_eq!( + config.memory_sources_json().expect("source JSON"), + serde_json::json!([{"id": "source-1"}]) + ); + + config + .set_memory_sources_json(serde_json::json!([{"id": "source-2"}])) + .expect("replace source JSON"); + assert_eq!( + config.memory_sources_json().expect("source JSON"), + serde_json::json!([{"id": "source-2"}]) + ); + config + .save() + .await + .expect("the in-memory adapter save is a no-op"); +} diff --git a/crates/tinymemory-tinycortex/tests/full_provider_conformance.rs b/crates/tinymemory-tinycortex/tests/full_provider_conformance.rs index 38865e0..d2cb872 100644 --- a/crates/tinymemory-tinycortex/tests/full_provider_conformance.rs +++ b/crates/tinymemory-tinycortex/tests/full_provider_conformance.rs @@ -221,3 +221,362 @@ async fn namespaced_kv_reads_apply_the_write_path_canonicalization() { "namespaced kv_delete must stay symmetric with kv_put" ); } + +#[tokio::test(flavor = "multi_thread")] +async fn document_source_graph_goals_and_tool_rule_state_transitions_round_trip() { + use tinymemory_api::goals::{GoalItem, GoalsDoc}; + use tinymemory_api::provider::types::SourceItem; + use tinymemory_api::provider::MemoryProvider; + use tinymemory_api::tool_memory::{ToolMemoryPriority, ToolMemoryRule, ToolMemorySource}; + use tinymemory_api::types::{GraphRelationRecord, MemoryTaint, NamespaceDocumentInput}; + + let workspace = tempfile::tempdir().expect("workspace"); + let provider = provider_over(workspace.path()); + + let documents = provider.as_documents().expect("Documents"); + let input = NamespaceDocumentInput { + namespace: "project".into(), + key: "brief".into(), + title: "Brief".into(), + content: "Ship the deterministic test suite".into(), + source_type: "upload".into(), + priority: "high".into(), + tags: vec!["tests".into()], + metadata: serde_json::json!({"ticket": 81}), + category: "core".into(), + session_id: Some("session-1".into()), + document_id: None, + taint: MemoryTaint::ExternalSync, + }; + let document_id = documents.put_document(input).await.expect("put document"); + let document = documents + .get_document("project", "brief") + .await + .expect("get document") + .expect("document present"); + assert_eq!(document.document_id, document_id); + assert_eq!(document.metadata, serde_json::json!({"ticket": 81})); + assert_eq!(document.taint, MemoryTaint::ExternalSync); + assert!(documents + .list_namespaces() + .await + .expect("namespaces") + .contains(&"project".into())); + documents + .delete_document("project", &document_id) + .await + .expect("delete document"); + assert!(documents + .get_document("project", "brief") + .await + .expect("get after delete") + .is_none()); + + let source = provider.as_sources().expect("SourceSink"); + let outcome = source + .accept_source_items( + "drive-1", + "drive", + vec![SourceItem { + item_id: "item-1".into(), + title: "Source item".into(), + content: "source body".into(), + mime: Some("text/plain".into()), + url: Some("https://example.invalid/item-1".into()), + updated_at_ms: Some(42), + tags: vec!["source".into()], + }], + MemoryTaint::ExternalSync, + ) + .await + .expect("accept source item"); + assert_eq!(outcome.written, 1); + assert_eq!( + source + .forget_source("drive-1") + .await + .expect("forget source"), + 1 + ); + + let graph = provider.as_graph().expect("Graph"); + let relation = GraphRelationRecord { + namespace: Some("project".into()), + subject: "suite".into(), + predicate: "covers".into(), + object: "adapter".into(), + attrs: serde_json::json!({"confidence": 1.0}), + updated_at: 0.0, + evidence_count: 0, + order_index: None, + document_ids: Vec::new(), + chunk_ids: Vec::new(), + }; + graph.put_relation(relation).await.expect("put relation"); + let relations = graph + .relations(Some("project"), Some("suite"), Some("covers"), 1) + .await + .expect("relations"); + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].object, "ADAPTER"); + assert!(graph + .relations(Some("project"), None, None, 0) + .await + .expect("zero limit") + .is_empty()); + + let goals = provider.as_goals().expect("Goals"); + let expected_goals = GoalsDoc { + items: vec![GoalItem::new("g1", "finish coverage")], + }; + goals + .set_goals(expected_goals.clone()) + .await + .expect("set goals"); + assert_eq!(goals.goals().await.expect("goals"), expected_goals); + + let tools = provider.as_tool_memory().expect("ToolMemory"); + let rule = ToolMemoryRule::new( + "shell", + "never delete broad paths", + ToolMemoryPriority::Critical, + ToolMemorySource::UserExplicit, + ); + let rule_id = rule.id.clone(); + tools.put_tool_rule(rule).await.expect("put tool rule"); + let rules = tools.tool_rules("shell").await.expect("tool rules"); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].id, rule_id); + assert!(tools + .delete_tool_rule("shell", &rule_id) + .await + .expect("delete rule")); + assert!(!tools + .delete_tool_rule("shell", &rule_id) + .await + .expect("delete missing rule")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn people_profile_and_episodic_lifecycles_are_real_and_typed() { + use tinymemory_api::error::MemoryError; + use tinymemory_api::provider::{ + EpisodicTurn, FacetType, MemoryProvider, PersonHandle, PersonInteraction, UserState, + }; + + let workspace = tempfile::tempdir().expect("workspace"); + let provider = provider_over(workspace.path()); + + let people = provider.as_people().expect("People"); + let handle = PersonHandle::Email(" Friend@Example.com ".into()); + assert!(people + .resolve_handle(&handle, false) + .await + .expect("resolve absent") + .is_none()); + let resolved = people + .resolve_handle(&handle, true) + .await + .expect("resolve/create") + .expect("person created"); + assert!(resolved.created); + assert_eq!( + people + .resolve_handle(&PersonHandle::Email("friend@example.com".into()), false) + .await + .expect("resolve canonical") + .expect("same person") + .id, + resolved.id + ); + assert!(matches!( + people + .record_interaction(&PersonInteraction { + person_id: resolved.id.clone(), + at: "not-a-time".into(), + is_outbound: true, + length: 10, + }) + .await, + Err(MemoryError::Invalid(_)) + )); + people + .record_interaction(&PersonInteraction { + person_id: resolved.id.clone(), + at: "2026-08-21T00:00:00Z".into(), + is_outbound: true, + length: 120, + }) + .await + .expect("record interaction"); + assert_eq!( + people + .score_person(&resolved.id) + .await + .expect("score") + .expect("person score") + .interaction_count, + 1 + ); + + let profile = provider.as_profile().expect("Profile"); + profile + .upsert_provider_facet( + "facet-1", + FacetType::Preference, + "style/verbosity", + "concise", + 0.9, + Some("segment-1"), + 100.0, + ) + .await + .expect("upsert facet"); + let facet = profile + .get_facet("style/verbosity") + .await + .expect("get facet") + .expect("facet present"); + assert_eq!(facet.value, "concise"); + assert!(profile + .set_facet_user_state("style/verbosity", UserState::Pinned) + .await + .expect("pin facet")); + assert_eq!( + profile + .get_facet("style/verbosity") + .await + .expect("get pinned facet") + .expect("facet present") + .user_state, + UserState::Pinned + ); + assert!(profile + .delete_facet("style/verbosity") + .await + .expect("delete facet")); + + let episodic = provider.as_episodic().expect("Episodic"); + let turn_id = episodic + .insert_turn(&EpisodicTurn { + id: None, + session_id: "session-1".into(), + timestamp: 10.0, + role: "user".into(), + content: "remember the test".into(), + lesson: Some("verify state".into()), + tool_calls_json: None, + cost_microdollars: -1, + }) + .await + .expect("insert turn"); + let turns = episodic + .session_turns("session-1") + .await + .expect("session turns"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].id, Some(turn_id)); + assert_eq!(turns[0].cost_microdollars, 0, "negative costs clamp"); + episodic + .create_segment("seg-1", "session-1", "global", turn_id, 10.0, 10.0) + .await + .expect("create segment"); + episodic + .append_turn("seg-1", turn_id, 10.0, 11.0) + .await + .expect("append turn"); + let segment = episodic + .open_segment("session-1") + .await + .expect("open segment") + .expect("segment present"); + assert_eq!(segment.turn_count, 2); + episodic + .close_segment("seg-1", 13.0) + .await + .expect("close segment"); + episodic + .set_segment_summary("seg-1", "one remembered turn", 14.0) + .await + .expect("set summary"); + assert!(episodic + .open_segment("session-1") + .await + .expect("open segment after close") + .is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ingest_chunk_and_retrieval_validation_paths_are_exercised_without_network() { + use tinymemory_api::chunks::DataSource; + use tinymemory_api::error::MemoryError; + use tinymemory_api::provider::types::IngestItem; + use tinymemory_api::provider::{ChunkQuery, FastRetrieveQuery, MemoryProvider}; + use tinymemory_api::types::MemoryTaint; + + let workspace = tempfile::tempdir().expect("workspace"); + let provider = provider_over(workspace.path()); + let ingest = provider.as_ingest().expect("Ingest"); + let invalid = IngestItem { + namespace: None, + source: DataSource::Upload, + source_id: "upload-1".into(), + owner: "owner".into(), + source_ref: None, + content: "binary".into(), + mime: Some("application/pdf".into()), + timestamp: None, + tags: Vec::new(), + taint: MemoryTaint::Internal, + path_scope: None, + }; + assert!(matches!( + ingest.ingest_document(invalid).await, + Err(MemoryError::Invalid(_)) + )); + assert!(ingest + .ingest_chat(Vec::new()) + .await + .expect("empty chat") + .ids + .is_empty()); + + let chunks = provider.as_chunks().expect("Chunks"); + assert!(chunks + .list_chunks(&ChunkQuery::default(), None) + .await + .expect("empty chunk list") + .is_empty()); + assert!(chunks + .get_chunk("missing") + .await + .expect("missing chunk") + .is_none()); + assert!(!chunks + .storage_kinds() + .await + .expect("storage kinds") + .is_empty()); + + let retrieval = provider.as_retrieval().expect("Retrieval"); + assert!(matches!( + retrieval + .fast_retrieve( + " ", + FastRetrieveQuery { + limit: 5, + max_hops: 1, + time_window_days: None, + }, + None, + ) + .await, + Err(MemoryError::Invalid(_)) + )); + assert!(matches!( + retrieval + .search_entities("x", Some(&["not-a-kind".to_string()]), 5) + .await, + Err(MemoryError::Invalid(_)) + )); +} From e3edc5e6df638bf9e657d4bbf3a71d0082043701 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 21 Aug 2026 12:35:20 +0300 Subject: [PATCH 06/45] Address adapter coverage review findings Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinymemory-module/src/service/mod.rs | 60 ++++- crates/tinymemory-module/src/service/test.rs | 129 ++++++++-- crates/tinymemory-module/tests/module_e2e.rs | 49 +++- crates/tinymemory-testing-ui/Cargo.toml | 3 + crates/tinymemory-testing-ui/src/main.rs | 52 +++- crates/tinymemory-testing-ui/src/test.rs | 242 +++++++++++++++--- crates/tinymemory-testing-ui/web/index.html | 46 +--- crates/tinymemory-testing-ui/web/workflows.js | 40 +++ .../web/workflows.test.js | 46 ++++ .../tests/full_provider_conformance.rs | 242 +++++++++++++++++- 11 files changed, 798 insertions(+), 112 deletions(-) create mode 100644 crates/tinymemory-testing-ui/web/workflows.js create mode 100644 crates/tinymemory-testing-ui/web/workflows.test.js diff --git a/Cargo.lock b/Cargo.lock index 0014b03..f39c9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2056,6 +2056,7 @@ dependencies = [ "tokio", "tower", "tower-http", + "url", ] [[package]] diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index eba50ab..7a1ca43 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -194,6 +194,12 @@ pub(crate) struct StoreOpener { /// through and produce exactly the double-open it is here to prevent. That /// is why this is a `tokio::sync::Mutex`. served: Mutex>, + #[cfg(test)] + allocation_attempts: std::sync::atomic::AtomicUsize, + #[cfg(test)] + registration_attempts: std::sync::atomic::AtomicUsize, + #[cfg(test)] + registration_failures: std::sync::atomic::AtomicUsize, } impl MemoryService { @@ -220,8 +226,20 @@ impl StoreOpener { connection, config, served: Mutex::new(HashMap::new()), + #[cfg(test)] + allocation_attempts: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + registration_attempts: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + registration_failures: std::sync::atomic::AtomicUsize::new(0), } } + + #[cfg(test)] + fn fail_registrations(&self, count: usize) { + self.registration_failures + .store(count, std::sync::atomic::Ordering::SeqCst); + } } /// Object path for a store rooted at `memory_subdir`. @@ -231,6 +249,8 @@ impl StoreOpener { /// this from a profile id, and an id that fails validation must produce a /// refusal, not a malformed path. fn object_path_for_subdir(memory_subdir: &str) -> Option { + const HEX: &[u8; 16] = b"0123456789abcdef"; + if memory_subdir.is_empty() || memory_subdir.len() > 128 || !memory_subdir @@ -239,7 +259,21 @@ fn object_path_for_subdir(memory_subdir: &str) -> Option { { return None; } - Some(format!("{OBJECT_PATH}/stores/{memory_subdir}")) + + // TinyBus object-path elements accept ASCII alphanumerics and `_`, but a + // profile id commonly contains `-`. Escape both punctuation characters so + // the mapping remains injective (`a-b` cannot collide with `a_2db`). + let mut component = String::with_capacity(memory_subdir.len()); + for byte in memory_subdir.bytes() { + if byte.is_ascii_alphanumeric() { + component.push(char::from(byte)); + } else { + component.push('_'); + component.push(char::from(HEX[usize::from(byte >> 4)])); + component.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + Some(format!("{OBJECT_PATH}/stores/{component}")) } macro_rules! require_family { @@ -365,6 +399,10 @@ impl MemoryService { }); } + #[cfg(test)] + opener + .allocation_attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); let client = tinymemory_core::store::factories::create_memory_client_in_subdir( &opener.config.memory, None, @@ -385,6 +423,26 @@ impl MemoryService { })?; let provider = crate::provider::provider(&opener.config, Arc::new(client)); + #[cfg(test)] + { + opener + .registration_attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if opener + .registration_failures + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + return Err(BusError::MethodFailed { + name: "ai.tinyhumans.tinymemory.Error.Other".to_string(), + message: "injected store registration failure".to_string(), + }); + } + } opener .connection .serve_at( diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index 80befc0..178085e 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -30,9 +30,32 @@ async fn test_connection() -> tinybus::Connection { let bus = MemoryBus::new(); let broker = tinybus::broker::Broker::new(); let _broker_task = broker.spawn(bus.clone()); - tinybus::Connection::connect(bus.connect().await.expect("test transport")) + let connection = tinybus::Connection::connect(bus.connect().await.expect("test transport")) .await - .expect("test connection") + .expect("test connection"); + connection + .request_name(super::BUS_NAME) + .await + .expect("claim test service name"); + connection +} + +fn test_config(workspace: &std::path::Path) -> crate::config::ModuleConfig { + crate::config::ModuleConfig { + workspace_dir: workspace.to_path_buf(), + ..crate::config::ModuleConfig::default() + } +} + +fn test_opener( + connection: tinybus::Connection, + workspace: &std::path::Path, +) -> std::sync::Arc { + let config = test_config(workspace); + tinymemory_core::embedding_host::set_embedding_host(std::sync::Arc::new( + crate::embedding::BusEmbeddingHost::new(connection.clone(), &config), + )); + std::sync::Arc::new(super::StoreOpener::new(connection, config)) } /// The name and message a mapped error carries on the wire. @@ -243,14 +266,25 @@ fn the_per_entry_overhead_is_counted_so_many_tiny_entries_still_trip_it() { #[test] fn store_object_paths_accept_only_one_safe_identifier_component() { - let valid = ["profile-1", "profile_one", "A9", &"x".repeat(128)]; - for subdir in valid { + let valid = [ + ("profile-1", "profile_2d1".to_string()), + ("profile_one", "profile_5fone".to_string()), + ("A9", "A9".to_string()), + (&"x".repeat(128), "x".repeat(128)), + ]; + for (subdir, component) in valid { assert_eq!( super::object_path_for_subdir(subdir), - Some(format!("{}/stores/{subdir}", super::OBJECT_PATH)) + Some(format!("{}/stores/{component}", super::OBJECT_PATH)) ); } + assert_ne!( + super::object_path_for_subdir("a-b"), + super::object_path_for_subdir("a_2db"), + "escaped identifiers must not collide" + ); + for invalid in [ "", ".", @@ -286,20 +320,17 @@ async fn a_leaf_store_cannot_recursively_open_another_store() { #[tokio::test] async fn repeated_and_concurrent_opens_reuse_the_registered_object_path() { + use std::sync::atomic::Ordering; use std::sync::Arc; + let workspace = tempfile::tempdir().expect("tempdir"); let connection = test_connection().await; - let opener = Arc::new(super::StoreOpener::new( - connection, - crate::config::ModuleConfig::default(), + let opener = test_opener(connection.clone(), workspace.path()); + let expected = format!("{}/stores/profile_2d1", super::OBJECT_PATH); + let service = Arc::new(super::MemoryService::root( + test_provider(), + Arc::clone(&opener), )); - let expected = format!("{}/stores/profile-1", super::OBJECT_PATH); - opener - .served - .lock() - .await - .insert("profile-1".to_string(), expected.clone()); - let service = Arc::new(super::MemoryService::root(test_provider(), opener)); let mut tasks = Vec::new(); for _ in 0..16 { @@ -311,23 +342,59 @@ async fn repeated_and_concurrent_opens_reuse_the_registered_object_path() { for task in tasks { assert_eq!(task.await.expect("join").expect("reused store"), expected); } + assert_eq!(opener.allocation_attempts.load(Ordering::SeqCst), 1); + assert_eq!(opener.registration_attempts.load(Ordering::SeqCst), 1); + assert_eq!(opener.served.lock().await.len(), 1); + + let driver_id: String = connection + .proxy(super::BUS_NAME, &expected, super::BUS_NAME) + .expect("store proxy") + .call("DriverId", ()) + .await + .expect("the newly registered object must answer"); + assert_eq!(driver_id, "tinycortex"); } #[tokio::test] -async fn the_open_store_cap_is_enforced_before_allocating_another_store() { +async fn a_failed_registration_is_retried_and_only_success_counts_toward_the_cap() { + use std::sync::atomic::Ordering; use std::sync::Arc; - let opener = Arc::new(super::StoreOpener::new( - test_connection().await, - crate::config::ModuleConfig::default(), - )); - { - let mut served = opener.served.lock().await; - for index in 0..super::MAX_OPEN_STORES { - served.insert(format!("profile-{index}"), format!("/served/{index}")); - } - } + let workspace = tempfile::tempdir().expect("tempdir"); + let opener = test_opener(test_connection().await, workspace.path()); + opener.fail_registrations(1); let service = super::MemoryService::root(test_provider(), Arc::clone(&opener)); + service + .open_store("retry".to_string()) + .await + .expect_err("the first registration is injected to fail"); + assert!(opener.served.lock().await.is_empty()); + + let path = service + .open_store("retry".to_string()) + .await + .expect("the same subtree must be retried"); + assert_eq!(path, format!("{}/stores/retry", super::OBJECT_PATH)); + assert_eq!(opener.allocation_attempts.load(Ordering::SeqCst), 2); + assert_eq!(opener.registration_attempts.load(Ordering::SeqCst), 2); + assert_eq!(opener.served.lock().await.len(), 1); +} + +#[tokio::test] +async fn the_open_store_cap_is_reached_through_successful_opens() { + use std::sync::atomic::Ordering; + use std::sync::Arc; + + let workspace = tempfile::tempdir().expect("tempdir"); + let opener = test_opener(test_connection().await, workspace.path()); + let service = super::MemoryService::root(test_provider(), Arc::clone(&opener)); + + for index in 0..super::MAX_OPEN_STORES { + service + .open_store(format!("profile-{index}")) + .await + .unwrap_or_else(|error| panic!("successful open {index} failed: {error}")); + } let error = service .open_store("one-more".to_string()) .await @@ -338,6 +405,16 @@ async fn the_open_store_cap_is_enforced_before_allocating_another_store() { assert_eq!(name, tinymemory_api::wire::INVALID); assert!(message.contains(&super::MAX_OPEN_STORES.to_string())); assert_eq!(opener.served.lock().await.len(), super::MAX_OPEN_STORES); + assert_eq!( + opener.allocation_attempts.load(Ordering::SeqCst), + super::MAX_OPEN_STORES, + "the refused open must not allocate" + ); + assert_eq!( + opener.registration_attempts.load(Ordering::SeqCst), + super::MAX_OPEN_STORES, + "the refused open must not register" + ); } /// Every method the service implements must also be declared in the manifest. diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 7c24f77..9403861 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -1,6 +1,6 @@ //! The real thing: a `dlopen`ed `cdylib`, a real broker, a real store. //! -//! # Why every test here is `#[ignore]`d +//! # Why loader cases are marked `#[ignore]` //! //! Not flakiness — a runtime constraint that cannot be worked around inside a //! single test binary. @@ -13,7 +13,9 @@ //! it fires rather than failing cleanly. //! //! So a test that drives a real module must be the only one running in its -//! process. Run them one at a time: +//! process. [`all_loader_cases_run_in_isolated_processes`] is part of the normal +//! suite and re-executes this test binary once per ignored loader case. To run a +//! single case manually: //! //! ```sh //! # Both paths are the module's own workspace, not the repo root: this crate is @@ -61,6 +63,49 @@ const DIMS: usize = 8; /// is not shared between tests in practice. static EMBED_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +const LOADER_CASES: &[&str] = &[ + "the_module_advertises_the_complete_tinymemory_api", + "an_entry_stored_over_the_bus_is_read_back", + "a_missing_entry_is_none_and_not_an_error", + "recall_reaches_the_host_embedder", + "an_export_page_terminates_on_a_none_cursor", + "a_rejected_request_comes_back_under_its_contract_name", + "the_module_matches_the_in_process_engine_for_the_same_input", + "the_manifest_declares_every_method_the_module_serves", + "what_is_written_lands_in_the_workspace_it_was_given", + "every_declared_method_is_actually_routed", +]; + +#[test] +fn all_loader_cases_run_in_isolated_processes() { + let test_binary = std::env::current_exe().expect("current test executable"); + let artifact = std::env::var_os("TINYMEMORY_TEST_MODULE").unwrap_or_else(|| { + test_binary + .parent() + .expect("test executable lives under target//deps") + .join(format!( + "{}tinymemory_module{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + )) + .into_os_string() + }); + assert!( + std::path::Path::new(&artifact).is_file(), + "module artifact does not exist at {}", + std::path::Path::new(&artifact).display() + ); + + for case in LOADER_CASES { + let status = std::process::Command::new(&test_binary) + .args(["--ignored", "--exact", case, "--nocapture"]) + .env("TINYMEMORY_TEST_MODULE", &artifact) + .status() + .unwrap_or_else(|error| panic!("could not run {case}: {error}")); + assert!(status.success(), "isolated loader case {case} failed"); + } +} + /// Stands in for the host's embedder so recall has something to work with. /// /// Deterministic rather than random: a recall assertion that depended on a diff --git a/crates/tinymemory-testing-ui/Cargo.toml b/crates/tinymemory-testing-ui/Cargo.toml index f136fc3..45f696b 100644 --- a/crates/tinymemory-testing-ui/Cargo.toml +++ b/crates/tinymemory-testing-ui/Cargo.toml @@ -35,6 +35,9 @@ tower-http = { version = "0.6", features = ["fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" +# Parse URL authority fields before fetch so credentials can be rejected +# without ever reflecting them through an upstream error message. +url = "2" [dev-dependencies] # HTTP-level tests exercise the Axum router without binding a port. diff --git a/crates/tinymemory-testing-ui/src/main.rs b/crates/tinymemory-testing-ui/src/main.rs index 42e08d2..61b0706 100644 --- a/crates/tinymemory-testing-ui/src/main.rs +++ b/crates/tinymemory-testing-ui/src/main.rs @@ -6,7 +6,9 @@ //! `store`/`recall`/`list`/`export` against it directly. See this crate's //! `README.md` for how to run it. +use std::future::Future; use std::net::SocketAddr; +use std::pin::Pin; use std::sync::Arc; use axum::extract::{Multipart, Query, State}; @@ -28,9 +30,17 @@ use tinymemory_documents::ingest::{DocumentIntake, IntakeRequest}; struct AppState { active: RwLock>>, + url_fetcher: UrlFetcher, } type SharedState = Arc; +type FetchFuture = + Pin> + Send>>; +type UrlFetcher = Arc FetchFuture + Send + Sync>; + +fn guarded_url_fetcher() -> UrlFetcher { + Arc::new(|url| Box::pin(async move { tinymemory_documents::fetch::fetch_url(&url).await })) +} /// A JSON-friendly wrapper around [`tinymemory_api::error::MemoryError`] and /// this harness's own connection-state errors. @@ -591,7 +601,10 @@ async fn ingest_url( Json(req): Json, ) -> Result { let provider = current(&state).await?; - let document = tinymemory_documents::fetch::fetch_url(&req.url).await?; + let url = validate_ingest_url(&req.url)?; + let document = (state.url_fetcher)(url) + .await + .map_err(safe_url_fetch_error)?; let request = intake_request( IntakeRequest::from_url(req.namespace), req.key, @@ -606,6 +619,42 @@ async fn ingest_url( Ok(Json(receipt).into_response()) } +/// Preserve the fetch error's HTTP class without reflecting its URL. Query +/// strings often carry signed tokens, and the fetch layer includes its input +/// URL in diagnostic errors intended for trusted library callers. +fn safe_url_fetch_error(error: tinymemory_api::error::MemoryError) -> ApiError { + use tinymemory_api::error::MemoryError; + + let message = match &error { + MemoryError::Invalid(_) => "URL is not an allowed fetch target", + MemoryError::BudgetExceeded(_) => "URL response exceeds document size limit", + _ => "URL fetch failed", + }; + let ApiError(status, _) = ApiError::from(error); + ApiError(status, message.to_string()) +} + +/// Validate sensitive URL fields before the fetch layer can include them in +/// an error. The document fetcher remains responsible for SSRF, redirects, +/// DNS pinning, and response-size policy. +fn validate_ingest_url(raw: &str) -> Result { + let url = url::Url::parse(raw) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "invalid URL".to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "URL scheme must be http or https".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "URL credentials are not allowed".to_string(), + )); + } + Ok(url.to_string()) +} + /// Apply the optional intake fields both intake endpoints share. fn intake_request( base: IntakeRequest, @@ -659,6 +708,7 @@ fn app(state: SharedState, web_dir: impl Into) -> Router { async fn main() { let state: SharedState = Arc::new(AppState { active: RwLock::new(None), + url_fetcher: guarded_url_fetcher(), }); let web_dir = std::env::var("TINYMEMORY_TESTING_UI_WEB") diff --git a/crates/tinymemory-testing-ui/src/test.rs b/crates/tinymemory-testing-ui/src/test.rs index 627572b..5de7ff2 100644 --- a/crates/tinymemory-testing-ui/src/test.rs +++ b/crates/tinymemory-testing-ui/src/test.rs @@ -1,5 +1,6 @@ //! HTTP and static UI contract tests for the local testing harness. +use std::process::Command; use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -14,12 +15,12 @@ use tinymemory_api::error::MemoryError; use tinymemory_api::health::MemoryHealth; use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; use tinymemory_api::provider::{ - MemoryCore, MemoryDocuments, MemoryPortability, MemoryProvider, MemoryRecall, + MemoryCore, MemoryDocuments, MemoryGraph, MemoryPortability, MemoryProvider, MemoryRecall, }; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::types::{ - MemoryCategory, MemoryEntry, MemoryTaint, NamespaceDocumentInput, NamespaceRetrievalContext, - NamespaceSummary, StoredMemoryDocument, + GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, + NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, }; use super::*; @@ -27,6 +28,7 @@ use super::*; fn empty_state() -> SharedState { Arc::new(AppState { active: RwLock::new(None), + url_fetcher: guarded_url_fetcher(), }) } @@ -388,6 +390,7 @@ async fn document_upload_validates_required_parts_and_supported_formats() { #[derive(Default)] struct RecordingProvider { document: Mutex>, + relations: Vec, } #[async_trait] @@ -507,6 +510,61 @@ impl MemoryDocuments for RecordingProvider { } } +#[async_trait] +impl MemoryGraph for RecordingProvider { + async fn kv_get( + &self, + _namespace: Option<&str>, + _key: &str, + ) -> Result, MemoryError> { + Ok(None) + } + + async fn kv_put( + &self, + _namespace: Option<&str>, + _key: &str, + _value: Value, + ) -> Result<(), MemoryError> { + Ok(()) + } + + async fn kv_delete(&self, _namespace: Option<&str>, _key: &str) -> Result { + Ok(false) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + + async fn relations( + &self, + namespace: Option<&str>, + subject: Option<&str>, + predicate: Option<&str>, + limit: usize, + ) -> Result, MemoryError> { + Ok(self + .relations + .iter() + .filter(|edge| namespace.is_none_or(|value| edge.namespace.as_deref() == Some(value))) + .filter(|edge| subject.is_none_or(|value| edge.subject == value)) + .filter(|edge| predicate.is_none_or(|value| edge.predicate == value)) + .take(limit) + .cloned() + .collect()) + } + + async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { + Ok(()) + } +} + #[async_trait] impl MemoryProvider for RecordingProvider { fn driver_id(&self) -> &str { @@ -514,7 +572,9 @@ impl MemoryProvider for RecordingProvider { } fn capabilities(&self) -> Capabilities { - Capabilities::mandatory().with(Capability::Documents) + Capabilities::mandatory() + .with(Capability::Documents) + .with(Capability::Graph) } async fn health(&self) -> MemoryHealth { @@ -524,6 +584,10 @@ impl MemoryProvider for RecordingProvider { fn as_documents(&self) -> Option<&dyn MemoryDocuments> { Some(self) } + + fn as_graph(&self) -> Option<&dyn MemoryGraph> { + Some(self) + } } #[tokio::test] @@ -563,39 +627,141 @@ async fn text_upload_preserves_filename_tags_category_and_taint() { assert_eq!(document.metadata["source_format"], "plain_text"); } -#[test] -fn html_exposes_every_visible_operation_and_its_route_contract() { - let html = include_str!("../web/index.html"); - for id in [ - "connect-btn", - "disconnect-btn", - "store-btn", - "upload-btn", - "get-btn", - "recall-btn", - "list-btn", - "namespaces-btn", - "forget-btn", - "export-btn", - "graph-btn", - ] { - assert!(html.contains(&format!("id=\"{id}\"")), "missing #{id}"); - } - for route in [ - "/connect", - "/disconnect", - "/store", - "/get", - "/recall", - "/list", - "/namespaces", - "/forget", - "/export", - "/graph/relations", - ] { - assert!( - html.contains(&format!("\"{route}")), - "missing route {route}" - ); +#[tokio::test] +async fn graph_view_http_route_returns_a_bounded_renderable_view() { + let provider = Arc::new(RecordingProvider { + relations: vec![GraphRelationRecord { + namespace: Some("people".to_string()), + subject: "ada".to_string(), + predicate: "wrote".to_string(), + object: "notes".to_string(), + attrs: Value::Null, + updated_at: 0.0, + evidence_count: 1, + order_index: None, + document_ids: Vec::new(), + chunk_ids: Vec::new(), + }], + ..RecordingProvider::default() + }); + let state = empty_state(); + *state.active.write().await = Some(provider); + + let response = test_app(state) + .oneshot(json_request( + Method::POST, + "/api/graph/view", + json!({ + "namespace": "people", + "seeds": ["ada"], + "depth": 1, + "max_nodes": 8, + "max_edges": 8 + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["namespace"], "people"); + assert_eq!(body["seeds"], json!(["ada"])); + assert_eq!(body["nodes"].as_array().unwrap().len(), 2); + assert_eq!(body["edges"][0]["predicate"], "wrote"); +} + +fn state_with_fetch_error(error: MemoryError) -> SharedState { + let error = Arc::new(Mutex::new(Some(error))); + Arc::new(AppState { + active: RwLock::new(Some(Arc::new(RecordingProvider::default()))), + url_fetcher: Arc::new(move |_| { + let error = error + .lock() + .unwrap() + .take() + .expect("test fetcher is called exactly once"); + Box::pin(async move { Err(error) }) + }), + }) +} + +#[tokio::test] +async fn url_ingest_rejects_malformed_schemes_private_targets_and_credentials() { + let router = test_app(empty_state()); + connect_local(&router).await; + let cases = [ + ("not a URL", "invalid URL"), + ("file:///etc/passwd", "URL scheme must be http or https"), + ( + "http://127.0.0.1/private", + "URL is not an allowed fetch target", + ), + ( + "https://user:top-secret@example.com/private", + "URL credentials are not allowed", + ), + ]; + + for (url, message) in cases { + let response = router + .clone() + .oneshot(json_request( + Method::POST, + "/api/ingest/url", + json!({ "url": url, "namespace": "documents" }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{url}"); + let body = json_body(response).await; + assert_eq!(body["error"], message, "{url}"); + assert!(!body.to_string().contains("top-secret")); } } + +#[tokio::test] +async fn url_ingest_maps_size_and_blocked_redirect_failures_without_network() { + let cases = [ + ( + MemoryError::BudgetExceeded("response body exceeds 33554432-byte limit".to_string()), + StatusCode::PAYLOAD_TOO_LARGE, + "URL response exceeds document size limit", + ), + ( + MemoryError::Invalid( + "redirect from https://example.com/?api_key=top-secret is not allowed".to_string(), + ), + StatusCode::BAD_REQUEST, + "URL is not an allowed fetch target", + ), + ]; + + for (error, status, message) in cases { + let response = test_app(state_with_fetch_error(error)) + .oneshot(json_request( + Method::POST, + "/api/ingest/url", + json!({ "url": "https://example.com/document.txt", "namespace": "documents" }), + )) + .await + .unwrap(); + assert_eq!(response.status(), status); + let body = json_body(response).await; + assert_eq!(body["error"], message); + assert!(!body.to_string().contains("top-secret")); + } +} + +#[test] +fn browser_upload_workflow_contract_executes() { + let output = Command::new("node") + .args(["--test", "web/workflows.test.js"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("Node.js is required to test the browser workflow contract"); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/tinymemory-testing-ui/web/index.html b/crates/tinymemory-testing-ui/web/index.html index 43b874e..e8ce77b 100644 --- a/crates/tinymemory-testing-ui/web/index.html +++ b/crates/tinymemory-testing-ui/web/index.html @@ -248,7 +248,6 @@

Operations

-
@@ -331,6 +330,7 @@

Operations

+