test: add systematic test coverage for 7 bug pattern groups (#852)

Add ~105 test cases across 7 test groups identified in issue #852:

TG1 - Provider resolution (27 tests): Factory resolution, alias mapping,
      custom URLs, auth styles, credential wiring
TG2 - Config persistence (18 tests): Config defaults, TOML roundtrip,
      agent/memory config, workspace dirs
TG3 - Channel routing (14 tests): ChannelMessage identity contracts,
      SendMessage construction, Channel trait send/listen roundtrip
TG4 - Agent loop robustness (12 integration + 14 inline tests): Malformed
      tool calls, failing tools, iteration limits, empty responses, unicode
TG5 - Memory restart (14 tests): Dedup on same key, restart persistence,
      session scoping, recall, concurrent stores, categories
TG6 - Channel message splitting (8+8 inline tests): Code blocks at boundary,
      long words, emoji, CJK chars, whitespace edge cases
TG7 - Provider schema (21 tests): ChatMessage/ToolCall/ChatResponse
      serialization, tool_call_id preservation, auth style variants

Also fixes a bug in split_message_for_telegram() where byte-based indexing
could panic on multi-byte characters (emoji, CJK). Now uses char_indices()
consistent with the Discord split implementation.

Closes #852

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Gorevski 2026-02-18 15:28:34 -08:00
parent b43e9eb325
commit 7f03ab77a9
9 changed files with 2272 additions and 8 deletions

335
tests/memory_restart.rs Normal file
View file

@ -0,0 +1,335 @@
//! TG5: Memory Restart Resilience Tests
//!
//! Prevents: Pattern 5 — Memory & state persistence bugs (10% of user bugs).
//! Issues: #430, #693, #802
//!
//! Tests SqliteMemory deduplication on restart, session scoping, concurrent
//! message ordering, and recall behavior after re-initialization.
use std::sync::Arc;
use zeroclaw::memory::sqlite::SqliteMemory;
use zeroclaw::memory::traits::{Memory, MemoryCategory};
// ─────────────────────────────────────────────────────────────────────────────
// Deduplication: same key overwrites instead of duplicating (#430)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_store_same_key_deduplicates() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
// Store same key twice with different content
mem.store("greeting", "hello world", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("greeting", "hello updated", MemoryCategory::Core, None)
.await
.unwrap();
// Should have exactly 1 entry, not 2
let count = mem.count().await.unwrap();
assert_eq!(count, 1, "storing same key twice should not create duplicates");
// Content should be the latest version
let entry = mem.get("greeting").await.unwrap().expect("entry should exist");
assert_eq!(entry.content, "hello updated");
}
#[tokio::test]
async fn sqlite_memory_store_different_keys_creates_separate_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("key_a", "content a", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("key_b", "content b", MemoryCategory::Core, None)
.await
.unwrap();
let count = mem.count().await.unwrap();
assert_eq!(count, 2, "different keys should create separate entries");
}
// ─────────────────────────────────────────────────────────────────────────────
// Restart resilience: data persists across memory re-initialization
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_persists_across_reinitialization() {
let tmp = tempfile::TempDir::new().unwrap();
// First "session": store data
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("persistent_fact", "Rust is great", MemoryCategory::Core, None)
.await
.unwrap();
}
// Second "session": re-create memory from same path
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
let entry = mem
.get("persistent_fact")
.await
.unwrap()
.expect("entry should survive reinitialization");
assert_eq!(entry.content, "Rust is great");
}
}
#[tokio::test]
async fn sqlite_memory_restart_does_not_duplicate_on_rewrite() {
let tmp = tempfile::TempDir::new().unwrap();
// First session: store entries
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact_1", "original content", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
.await
.unwrap();
}
// Second session: re-store same keys (simulates channel re-reading history)
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact_1", "original content", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
.await
.unwrap();
let count = mem.count().await.unwrap();
assert_eq!(
count, 2,
"re-storing same keys after restart should not create duplicates"
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Session scoping: messages scoped to sessions don't leak
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_session_scoped_store_and_recall() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
// Store in different sessions
mem.store(
"session_a_fact",
"fact from session A",
MemoryCategory::Conversation,
Some("session_a"),
)
.await
.unwrap();
mem.store(
"session_b_fact",
"fact from session B",
MemoryCategory::Conversation,
Some("session_b"),
)
.await
.unwrap();
// List scoped to session_a
let session_a_entries = mem
.list(Some(&MemoryCategory::Conversation), Some("session_a"))
.await
.unwrap();
assert_eq!(
session_a_entries.len(),
1,
"session_a should have exactly 1 entry"
);
assert_eq!(session_a_entries[0].content, "fact from session A");
}
#[tokio::test]
async fn sqlite_memory_global_recall_includes_all_sessions() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("global_a", "alpha content", MemoryCategory::Core, Some("s1"))
.await
.unwrap();
mem.store("global_b", "beta content", MemoryCategory::Core, Some("s2"))
.await
.unwrap();
// Global count should include all
let count = mem.count().await.unwrap();
assert_eq!(count, 2, "global count should include entries from all sessions");
}
// ─────────────────────────────────────────────────────────────────────────────
// Recall and search behavior
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_recall_returns_relevant_results() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("lang_pref", "User prefers Rust programming", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("food_pref", "User likes sushi for lunch", MemoryCategory::Core, None)
.await
.unwrap();
let results = mem.recall("Rust programming", 10, None).await.unwrap();
assert!(!results.is_empty(), "recall should find matching entries");
// The Rust-related entry should be in results
assert!(
results.iter().any(|e| e.content.contains("Rust")),
"recall for 'Rust' should include the Rust-related entry"
);
}
#[tokio::test]
async fn sqlite_memory_recall_respects_limit() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
for i in 0..10 {
mem.store(
&format!("entry_{i}"),
&format!("test content number {i}"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
let results = mem.recall("test content", 3, None).await.unwrap();
assert!(
results.len() <= 3,
"recall should respect limit of 3, got {}",
results.len()
);
}
#[tokio::test]
async fn sqlite_memory_recall_empty_query_returns_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact", "some content", MemoryCategory::Core, None)
.await
.unwrap();
let results = mem.recall("", 10, None).await.unwrap();
assert!(
results.is_empty(),
"empty query should return no results"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Forget and health check
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_forget_removes_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("to_forget", "temporary info", MemoryCategory::Core, None)
.await
.unwrap();
assert_eq!(mem.count().await.unwrap(), 1);
let removed = mem.forget("to_forget").await.unwrap();
assert!(removed, "forget should return true for existing key");
assert_eq!(mem.count().await.unwrap(), 0);
}
#[tokio::test]
async fn sqlite_memory_forget_nonexistent_returns_false() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
let removed = mem.forget("nonexistent_key").await.unwrap();
assert!(!removed, "forget should return false for nonexistent key");
}
#[tokio::test]
async fn sqlite_memory_health_check_returns_true() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
assert!(mem.health_check().await, "health_check should return true");
}
// ─────────────────────────────────────────────────────────────────────────────
// Concurrent access
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_concurrent_stores_no_data_loss() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = Arc::new(SqliteMemory::new(tmp.path()).unwrap());
let mut handles = Vec::new();
for i in 0..5 {
let mem_clone = mem.clone();
handles.push(tokio::spawn(async move {
mem_clone
.store(
&format!("concurrent_{i}"),
&format!("content from task {i}"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}));
}
for handle in handles {
handle.await.unwrap();
}
let count = mem.count().await.unwrap();
assert_eq!(
count, 5,
"all concurrent stores should succeed, got {count}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Memory categories
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_list_by_category() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("core_fact", "core info", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("daily_note", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
mem.store("conv_msg", "conversation msg", MemoryCategory::Conversation, None)
.await
.unwrap();
let core_entries = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
assert_eq!(core_entries.len(), 1, "should have 1 Core entry");
assert_eq!(core_entries[0].key, "core_fact");
let daily_entries = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
assert_eq!(daily_entries.len(), 1, "should have 1 Daily entry");
}