use super::context::format_user_profile;
use super::tasks::{descriptions_are_similar, normalize_due_at};
use super::Store;
use kernex_core::config::MemoryConfig;
use kernex_core::context::{CompactionStrategy, ContextNeeds};
use kernex_core::error::Result as KernexResult;
use kernex_core::message::Request;
use kernex_core::traits::Summarizer;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
async fn test_store() -> Store {
let _config = MemoryConfig {
backend: "sqlite".to_string(),
db_path: ":memory:".to_string(),
max_context_messages: 10,
..Default::default()
};
let opts = SqliteConnectOptions::from_str("sqlite::memory:")
.unwrap()
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
Store::run_migrations(&pool).await.unwrap();
Store {
pool,
max_context_messages: 10,
}
}
#[tokio::test]
async fn test_create_and_get_tasks() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Call John",
"2026-12-31T15:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
assert!(!id.is_empty());
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].1, "Call John");
assert_eq!(tasks[0].2, "2026-12-31 15:00:00");
assert!(tasks[0].3.is_none());
assert_eq!(tasks[0].4, "reminder");
}
#[tokio::test]
async fn test_get_due_tasks() {
let store = test_store().await;
store
.create_task(
"api",
"user1",
"chat1",
"Past task",
"2020-01-01T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
store
.create_task(
"api",
"user1",
"chat1",
"Future task",
"2099-12-31T23:59:59",
None,
"reminder",
"",
)
.await
.unwrap();
let due = store.get_due_tasks().await.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].description, "Past task");
assert_eq!(due[0].task_type, "reminder");
}
#[tokio::test]
async fn test_complete_one_shot() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"One shot",
"2020-01-01T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
store.complete_task(&id, None).await.unwrap();
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert!(tasks.is_empty());
let due = store.get_due_tasks().await.unwrap();
assert!(due.is_empty());
}
#[tokio::test]
async fn test_complete_recurring() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Daily standup",
"2020-01-01T09:00:00",
Some("daily"),
"reminder",
"",
)
.await
.unwrap();
store.complete_task(&id, Some("daily")).await.unwrap();
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].2, "2020-01-02 09:00:00");
}
#[tokio::test]
async fn test_cancel_task() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Cancel me",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let cancelled = store.cancel_task(prefix, "user1").await.unwrap();
assert!(cancelled);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert!(tasks.is_empty());
}
#[tokio::test]
async fn test_cancel_task_wrong_sender() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"My task",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let cancelled = store.cancel_task(prefix, "user2").await.unwrap();
assert!(!cancelled);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1);
}
#[tokio::test]
async fn test_update_task_description() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Old desc",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let updated = store
.update_task(prefix, "user1", Some("New desc"), None, None)
.await
.unwrap();
assert!(updated);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks[0].1, "New desc");
}
#[tokio::test]
async fn test_update_task_repeat() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"My task",
"2099-12-31T00:00:00",
Some("once"),
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let updated = store
.update_task(prefix, "user1", None, None, Some("daily"))
.await
.unwrap();
assert!(updated);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks[0].3, Some("daily".to_string()));
}
#[tokio::test]
async fn test_update_task_wrong_sender() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"My task",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let updated = store
.update_task(prefix, "user2", Some("Hacked"), None, None)
.await
.unwrap();
assert!(!updated);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks[0].1, "My task");
}
#[tokio::test]
async fn test_update_task_no_fields() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"My task",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
let prefix = &id[..8];
let updated = store
.update_task(prefix, "user1", None, None, None)
.await
.unwrap();
assert!(!updated);
}
#[tokio::test]
async fn test_create_task_with_action_type() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Check BTC price",
"2026-12-31T14:00:00",
Some("daily"),
"action",
"",
)
.await
.unwrap();
assert!(!id.is_empty());
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].1, "Check BTC price");
assert_eq!(tasks[0].4, "action");
}
#[tokio::test]
async fn test_get_due_tasks_returns_task_type() {
let store = test_store().await;
store
.create_task(
"api",
"user1",
"chat1",
"Reminder task",
"2020-01-01T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
store
.create_task(
"api",
"user1",
"chat1",
"Action task",
"2020-01-01T00:00:00",
None,
"action",
"",
)
.await
.unwrap();
let due = store.get_due_tasks().await.unwrap();
assert_eq!(due.len(), 2);
let reminder = due
.iter()
.find(|t| t.description == "Reminder task")
.unwrap();
let action = due.iter().find(|t| t.description == "Action task").unwrap();
assert_eq!(reminder.task_type, "reminder");
assert_eq!(action.task_type, "action");
}
#[tokio::test]
async fn test_create_task_dedup() {
let store = test_store().await;
let id1 = store
.create_task(
"api",
"user1",
"chat1",
"Close all positions",
"2026-02-20T14:30:00",
None,
"action",
"",
)
.await
.unwrap();
let id2 = store
.create_task(
"api",
"user1",
"chat1",
"Close all positions",
"2026-02-20T14:30:00",
None,
"action",
"",
)
.await
.unwrap();
assert_eq!(id1, id2);
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1);
}
#[tokio::test]
async fn test_get_fact() {
let store = test_store().await;
assert!(store.get_fact("user1", "color").await.unwrap().is_none());
store.store_fact("user1", "color", "blue").await.unwrap();
assert_eq!(
store.get_fact("user1", "color").await.unwrap(),
Some("blue".to_string())
);
}
#[tokio::test]
async fn test_delete_fact() {
let store = test_store().await;
assert!(!store.delete_fact("user1", "color").await.unwrap());
store.store_fact("user1", "color", "blue").await.unwrap();
assert!(store.delete_fact("user1", "color").await.unwrap());
assert!(store.get_fact("user1", "color").await.unwrap().is_none());
}
#[tokio::test]
async fn test_is_new_user() {
let store = test_store().await;
assert!(store.is_new_user("fresh_user").await.unwrap());
store
.store_fact("fresh_user", "welcomed", "true")
.await
.unwrap();
assert!(!store.is_new_user("fresh_user").await.unwrap());
}
#[tokio::test]
async fn test_get_all_facts() {
let store = test_store().await;
let facts = store.get_all_facts().await.unwrap();
assert!(facts.is_empty());
store.store_fact("user1", "name", "Alice").await.unwrap();
store.store_fact("user2", "name", "Bob").await.unwrap();
store.store_fact("user1", "timezone", "EST").await.unwrap();
store.store_fact("user1", "welcomed", "true").await.unwrap();
let facts = store.get_all_facts().await.unwrap();
assert_eq!(facts.len(), 3, "should exclude 'welcomed' facts");
assert!(facts.iter().any(|(k, v)| k == "name" && v == "Alice"));
assert!(facts.iter().any(|(k, v)| k == "name" && v == "Bob"));
assert!(facts.iter().any(|(k, v)| k == "timezone" && v == "EST"));
}
#[tokio::test]
async fn test_get_all_recent_summaries() {
let store = test_store().await;
let summaries = store.get_all_recent_summaries(3).await.unwrap();
assert!(summaries.is_empty());
sqlx::query(
"INSERT INTO conversations (id, channel, sender_id, status, summary, last_activity, updated_at) \
VALUES ('c1', 'api', 'user1', 'closed', 'Discussed project planning', datetime('now'), datetime('now'))",
)
.execute(store.pool())
.await
.unwrap();
let summaries = store.get_all_recent_summaries(3).await.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].0, "Discussed project planning");
}
#[tokio::test]
async fn test_store_limitation_new() {
let store = test_store().await;
let is_new = store
.store_limitation("No email", "Cannot send emails", "Add SMTP")
.await
.unwrap();
assert!(is_new, "first insert should return true");
}
#[tokio::test]
async fn test_store_limitation_duplicate() {
let store = test_store().await;
store
.store_limitation("No email", "Cannot send emails", "Add SMTP")
.await
.unwrap();
let is_new = store
.store_limitation("No email", "Different desc", "Different plan")
.await
.unwrap();
assert!(!is_new, "duplicate title should return false");
}
#[tokio::test]
async fn test_store_limitation_case_insensitive() {
let store = test_store().await;
store
.store_limitation("No Email", "Cannot send emails", "Add SMTP")
.await
.unwrap();
let is_new = store
.store_limitation("no email", "Different desc", "Different plan")
.await
.unwrap();
assert!(
!is_new,
"case-insensitive duplicate title should return false"
);
}
#[tokio::test]
async fn test_get_open_limitations() {
let store = test_store().await;
store
.store_limitation("No email", "Cannot send emails", "Add SMTP")
.await
.unwrap();
store
.store_limitation("No calendar", "Cannot access calendar", "Add Google Cal")
.await
.unwrap();
let limitations = store.get_open_limitations().await.unwrap();
assert_eq!(limitations.len(), 2);
assert_eq!(limitations[0].0, "No email");
assert_eq!(limitations[1].0, "No calendar");
}
#[test]
fn test_user_profile_filters_system_facts() {
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "English".to_string()),
("active_project".to_string(), "myproject".to_string()),
("name".to_string(), "Alice".to_string()),
];
let profile = format_user_profile(&facts);
assert!(profile.contains("name: Alice"));
assert!(!profile.contains("welcomed"));
assert!(!profile.contains("preferred_language"));
assert!(!profile.contains("active_project"));
}
#[test]
fn test_user_profile_groups_identity_first() {
let facts = vec![
("timezone".to_string(), "EST".to_string()),
("interests".to_string(), "chess".to_string()),
("name".to_string(), "Alice".to_string()),
("pronouns".to_string(), "she/her".to_string()),
("occupation".to_string(), "engineer".to_string()),
];
let profile = format_user_profile(&facts);
let lines: Vec<&str> = profile.lines().collect();
assert_eq!(lines[0], "User profile:");
let name_pos = lines.iter().position(|l| l.contains("name:")).unwrap();
let pronouns_pos = lines.iter().position(|l| l.contains("pronouns:")).unwrap();
let timezone_pos = lines.iter().position(|l| l.contains("timezone:")).unwrap();
let occupation_pos = lines
.iter()
.position(|l| l.contains("occupation:"))
.unwrap();
let interests_pos = lines.iter().position(|l| l.contains("interests:")).unwrap();
assert!(name_pos < timezone_pos);
assert!(pronouns_pos < timezone_pos);
assert!(timezone_pos < interests_pos);
assert!(occupation_pos < interests_pos);
}
#[test]
fn test_user_profile_empty_for_system_only() {
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "English".to_string()),
];
let profile = format_user_profile(&facts);
assert!(profile.is_empty());
}
#[test]
fn test_build_system_prompt_shows_action_badge() {
use super::context::{build_system_prompt, SystemPromptContext};
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "English".to_string()),
("name".to_string(), "Alice".to_string()),
("occupation".to_string(), "engineer".to_string()),
("timezone".to_string(), "EST".to_string()),
];
let tasks = vec![(
"abcd1234-0000".to_string(),
"Check BTC price".to_string(),
"2026-02-18T14:00:00".to_string(),
Some("daily".to_string()),
"action".to_string(),
String::new(),
)];
let prompt = build_system_prompt(&SystemPromptContext {
base_rules: "Rules",
facts: &facts,
summaries: &[],
recall: &[],
pending_tasks: &tasks,
outcomes: &[],
lessons: &[],
language: "English",
onboarding_hint: None,
});
assert!(
prompt.contains("[action]"),
"should show [action] badge for action tasks"
);
}
#[test]
fn test_onboarding_stage0_first_conversation() {
use super::context::{build_system_prompt, SystemPromptContext};
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "Spanish".to_string()),
];
let prompt = build_system_prompt(&SystemPromptContext {
base_rules: "Rules",
facts: &facts,
summaries: &[],
recall: &[],
pending_tasks: &[],
outcomes: &[],
lessons: &[],
language: "Spanish",
onboarding_hint: Some(0),
});
assert!(
prompt.contains("first conversation"),
"stage 0 should include first-conversation intro"
);
}
#[test]
fn test_onboarding_stage1_help_hint() {
use super::context::{build_system_prompt, SystemPromptContext};
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "English".to_string()),
("name".to_string(), "Alice".to_string()),
];
let prompt = build_system_prompt(&SystemPromptContext {
base_rules: "Rules",
facts: &facts,
summaries: &[],
recall: &[],
pending_tasks: &[],
outcomes: &[],
lessons: &[],
language: "English",
onboarding_hint: Some(1),
});
assert!(
prompt.contains("/help"),
"stage 1 should mention /help command"
);
}
#[test]
fn test_onboarding_no_hint_when_none() {
use super::context::{build_system_prompt, SystemPromptContext};
let facts = vec![
("welcomed".to_string(), "true".to_string()),
("preferred_language".to_string(), "English".to_string()),
("name".to_string(), "Alice".to_string()),
("occupation".to_string(), "engineer".to_string()),
("timezone".to_string(), "EST".to_string()),
];
let prompt = build_system_prompt(&SystemPromptContext {
base_rules: "Rules",
facts: &facts,
summaries: &[],
recall: &[],
pending_tasks: &[],
outcomes: &[],
lessons: &[],
language: "English",
onboarding_hint: None,
});
assert!(
!prompt.contains("Onboarding hint"),
"should NOT include onboarding hint when None"
);
assert!(
!prompt.contains("first conversation"),
"should NOT include first-conversation intro when None"
);
}
#[test]
fn test_compute_onboarding_stage_sequential() {
use super::context::compute_onboarding_stage;
assert_eq!(compute_onboarding_stage(0, 1, false), 1);
assert_eq!(compute_onboarding_stage(0, 0, false), 0);
assert_eq!(compute_onboarding_stage(1, 3, false), 2);
assert_eq!(compute_onboarding_stage(1, 2, false), 1);
assert_eq!(compute_onboarding_stage(2, 3, true), 3);
assert_eq!(compute_onboarding_stage(2, 3, false), 2);
assert_eq!(compute_onboarding_stage(3, 5, true), 4);
assert_eq!(compute_onboarding_stage(3, 4, true), 3);
assert_eq!(compute_onboarding_stage(4, 5, true), 5);
assert_eq!(compute_onboarding_stage(5, 10, true), 5);
}
#[test]
fn test_compute_onboarding_stage_no_skip() {
use super::context::compute_onboarding_stage;
assert_eq!(compute_onboarding_stage(0, 10, true), 1);
}
#[test]
fn test_onboarding_hint_text_contains_commands() {
use super::context::onboarding_hint_text;
let hint1 = onboarding_hint_text(1, "English").unwrap();
assert!(hint1.contains("/help"));
let hint2 = onboarding_hint_text(2, "English").unwrap();
assert!(hint2.contains("/personality"));
let hint3 = onboarding_hint_text(3, "English").unwrap();
assert!(hint3.contains("/tasks"));
let hint4 = onboarding_hint_text(4, "English").unwrap();
assert!(hint4.contains("/projects"));
assert!(onboarding_hint_text(5, "English").is_none());
}
#[test]
fn test_onboarding_hint_text_includes_language() {
use super::context::onboarding_hint_text;
let hint0 = onboarding_hint_text(0, "French").unwrap();
assert!(
hint0.contains("French"),
"stage 0 should reference the language"
);
for stage in 1..=4 {
let hint = onboarding_hint_text(stage, "German").unwrap();
assert!(
hint.contains("Respond in German"),
"stage {stage} should contain 'Respond in German'"
);
}
}
#[tokio::test]
async fn test_build_context_advances_onboarding_stage() {
let store = test_store().await;
let sender = "onboard_user";
let msg = Request::text(sender, "hello");
let needs = ContextNeeds::default();
let ctx = store
.build_context("api", &msg, "Base rules", &needs, None, None)
.await
.unwrap();
assert!(
ctx.system_prompt.contains("first conversation"),
"first contact should trigger stage 0 intro"
);
store.store_fact(sender, "welcomed", "true").await.unwrap();
store.store_fact(sender, "name", "Alice").await.unwrap();
let ctx2 = store
.build_context("api", &msg, "Base rules", &needs, None, None)
.await
.unwrap();
assert!(
ctx2.system_prompt.contains("/help"),
"after learning name, should show stage 1 /help hint"
);
let ctx3 = store
.build_context("api", &msg, "Base rules", &needs, None, None)
.await
.unwrap();
assert!(
!ctx3.system_prompt.contains("Onboarding hint"),
"no hint when stage hasn't changed"
);
}
struct MockSummarizer;
#[async_trait::async_trait]
impl Summarizer for MockSummarizer {
async fn summarize(&self, text: &str) -> KernexResult<String> {
Ok(format!("SUMMARY({}chars)", text.len()))
}
}
async fn compact_test_store(sender: &str, message_count: u32) -> (Store, String) {
let opts = SqliteConnectOptions::from_str("sqlite::memory:")
.unwrap()
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
Store::run_migrations(&pool).await.unwrap();
let store = Store {
pool,
max_context_messages: 2,
};
let conv_id = store
.get_or_create_conversation("api", sender, "")
.await
.unwrap();
for i in 0..message_count {
sqlx::query(
"INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, 'user', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&conv_id)
.bind(format!("msg_{i}"))
.execute(store.pool())
.await
.unwrap();
}
(store, conv_id)
}
#[tokio::test]
async fn test_compact_strategy_drop_no_summary_injected() {
let sender = "compact_drop_user";
let (store, _) = compact_test_store(sender, 4).await;
let msg = Request::text(sender, "latest");
let needs = ContextNeeds {
compact: CompactionStrategy::Drop,
..Default::default()
};
let ctx = store
.build_context("api", &msg, "base", &needs, None, Some(&MockSummarizer))
.await
.unwrap();
assert!(
!ctx.system_prompt.contains("Earlier conversation summary"),
"Drop strategy must not inject summary"
);
}
#[tokio::test]
async fn test_compact_strategy_summarize_injects_summary() {
let sender = "compact_summarize_user";
let (store, _) = compact_test_store(sender, 4).await;
let msg = Request::text(sender, "latest");
let needs = ContextNeeds {
compact: CompactionStrategy::Summarize,
..Default::default()
};
let ctx = store
.build_context("api", &msg, "base", &needs, None, Some(&MockSummarizer))
.await
.unwrap();
assert!(
ctx.system_prompt.contains("[Earlier conversation summary]"),
"Summarize strategy must inject summary header"
);
assert!(
ctx.system_prompt.contains("SUMMARY("),
"Summary text from MockSummarizer must appear in system prompt"
);
}
#[tokio::test]
async fn test_compact_no_overflow_no_summary() {
let sender = "compact_no_overflow_user";
let (store, _) = compact_test_store(sender, 1).await;
let msg = Request::text(sender, "latest");
let needs = ContextNeeds {
compact: CompactionStrategy::Summarize,
..Default::default()
};
let ctx = store
.build_context("api", &msg, "base", &needs, None, Some(&MockSummarizer))
.await
.unwrap();
assert!(
!ctx.system_prompt.contains("[Earlier conversation summary]"),
"No overflow means no summary injection"
);
}
#[tokio::test]
async fn test_resolve_sender_id_no_alias() {
let store = test_store().await;
let resolved = store.resolve_sender_id("phone123").await.unwrap();
assert_eq!(resolved, "phone123");
}
#[tokio::test]
async fn test_create_and_resolve_alias() {
let store = test_store().await;
store.create_alias("phone123", "tg456").await.unwrap();
let resolved = store.resolve_sender_id("phone123").await.unwrap();
assert_eq!(resolved, "tg456");
}
#[tokio::test]
async fn test_create_alias_idempotent() {
let store = test_store().await;
store.create_alias("phone123", "tg456").await.unwrap();
store.create_alias("phone123", "tg456").await.unwrap();
let resolved = store.resolve_sender_id("phone123").await.unwrap();
assert_eq!(resolved, "tg456");
}
#[tokio::test]
async fn test_find_canonical_user() {
let store = test_store().await;
assert!(store
.find_canonical_user("new_user")
.await
.unwrap()
.is_none());
store.store_fact("tg456", "welcomed", "true").await.unwrap();
let canonical = store
.find_canonical_user("phone123")
.await
.unwrap()
.unwrap();
assert_eq!(canonical, "tg456");
assert!(store.find_canonical_user("tg456").await.unwrap().is_none());
}
#[tokio::test]
async fn test_alias_shares_facts() {
let store = test_store().await;
store.store_fact("tg456", "name", "Alice").await.unwrap();
store.store_fact("tg456", "welcomed", "true").await.unwrap();
store.create_alias("phone123", "tg456").await.unwrap();
let resolved = store.resolve_sender_id("phone123").await.unwrap();
let facts = store.get_facts(&resolved).await.unwrap();
assert!(facts.iter().any(|(k, v)| k == "name" && v == "Alice"));
}
#[test]
fn test_normalize_due_at_strips_z() {
assert_eq!(
normalize_due_at("2026-02-22T07:00:00Z"),
"2026-02-22 07:00:00"
);
}
#[test]
fn test_normalize_due_at_replaces_t() {
assert_eq!(
normalize_due_at("2026-02-22T07:00:00"),
"2026-02-22 07:00:00"
);
}
#[test]
fn test_normalize_due_at_already_normalized() {
assert_eq!(
normalize_due_at("2026-02-22 07:00:00"),
"2026-02-22 07:00:00"
);
}
#[test]
fn test_descriptions_similar_email_variants() {
assert!(descriptions_are_similar(
"Enviar email de amor diario a Adriana (adri_navega@hotmail.com)",
"Enviar email de amor diario a Adriana (adri_navega@hotmail.com) — escribir un mensaje"
));
}
#[test]
fn test_descriptions_similar_hostinger() {
assert!(descriptions_are_similar(
"Cancel Hostinger plan — expires March 17",
"Cancel Hostinger VPS — last reminder, expires TOMORROW"
));
}
#[test]
fn test_descriptions_different() {
assert!(!descriptions_are_similar(
"Send good morning message to the team",
"Cancel Hostinger plan and subscription"
));
}
#[test]
fn test_descriptions_short_skipped() {
assert!(!descriptions_are_similar("Reminder task", "Action task"));
}
#[tokio::test]
async fn test_create_task_fuzzy_dedup() {
let store = test_store().await;
let id1 = store
.create_task(
"api",
"user1",
"reply1",
"Send daily email to Adriana",
"2026-02-22 07:00:00",
Some("daily"),
"action",
"",
)
.await
.unwrap();
let id2 = store
.create_task(
"api",
"user1",
"reply1",
"Send daily email to Adriana",
"2026-02-22T07:00:00Z",
Some("daily"),
"action",
"",
)
.await
.unwrap();
assert_eq!(id1, id2, "exact dedup with normalized datetime");
let id3 = store
.create_task(
"api",
"user1",
"reply1",
"Send daily love email to Adriana via gmail",
"2026-02-22 07:05:00",
Some("daily"),
"action",
"",
)
.await
.unwrap();
assert_eq!(id1, id3, "fuzzy dedup: similar description within 30min");
let id4 = store
.create_task(
"api",
"user2",
"reply2",
"Send daily email to Adriana",
"2026-02-22 07:00:00",
Some("daily"),
"action",
"",
)
.await
.unwrap();
assert_ne!(id1, id4, "different sender should create new task");
}
#[tokio::test]
async fn test_fail_task_retries() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Send email",
"2020-01-01T00:00:00",
None,
"action",
"",
)
.await
.unwrap();
let will_retry = store.fail_task(&id, "SMTP error", 3).await.unwrap();
assert!(will_retry, "should retry on first failure");
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 1, "task should still be pending");
let will_retry = store.fail_task(&id, "SMTP error again", 3).await.unwrap();
assert!(will_retry, "should retry on second failure");
let will_retry = store.fail_task(&id, "SMTP final error", 3).await.unwrap();
assert!(!will_retry, "should NOT retry after max retries");
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert!(tasks.is_empty(), "failed task should not appear in pending");
}
#[tokio::test]
async fn test_fail_task_stores_error() {
let store = test_store().await;
let id = store
.create_task(
"api",
"user1",
"chat1",
"Check price",
"2020-01-01T00:00:00",
None,
"action",
"",
)
.await
.unwrap();
store.fail_task(&id, "connection refused", 3).await.unwrap();
let row: Option<(String, i64)> =
sqlx::query_as("SELECT last_error, retry_count FROM scheduled_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(store.pool())
.await
.unwrap();
let (last_error, retry_count) = row.unwrap();
assert_eq!(last_error, "connection refused");
assert_eq!(retry_count, 1);
}
#[tokio::test]
async fn test_outcomes_project_isolation() {
let store = test_store().await;
store
.store_outcome(
"user1",
"communication",
1,
"Be concise",
"conversation",
"",
)
.await
.unwrap();
store
.store_outcome(
"user1",
"trading",
1,
"Check volume",
"conversation",
"my-trader",
)
.await
.unwrap();
let all = store.get_recent_outcomes("user1", 10, None).await.unwrap();
assert_eq!(all.len(), 2);
let trading = store
.get_recent_outcomes("user1", 10, Some("my-trader"))
.await
.unwrap();
assert_eq!(trading.len(), 1);
assert_eq!(trading[0].1, "trading");
let general = store
.get_recent_outcomes("user1", 10, Some(""))
.await
.unwrap();
assert_eq!(general.len(), 1);
assert_eq!(general[0].1, "communication");
}
#[tokio::test]
async fn test_lessons_project_layering() {
let store = test_store().await;
store
.store_lesson("user1", "communication", "Be concise", "")
.await
.unwrap();
store
.store_lesson("user1", "risk", "Never risk more than 2%", "my-trader")
.await
.unwrap();
let general = store.get_lessons("user1", None).await.unwrap();
assert_eq!(general.len(), 1);
assert_eq!(general[0].0, "communication");
assert_eq!(general[0].2, "");
let layered = store.get_lessons("user1", Some("my-trader")).await.unwrap();
assert_eq!(layered.len(), 2);
assert_eq!(
layered[0].2, "my-trader",
"project lesson should come first"
);
assert_eq!(layered[1].2, "", "general lesson should come second");
}
#[tokio::test]
async fn test_lessons_project_separate() {
let store = test_store().await;
store
.store_lesson("user1", "risk", "General risk rule", "")
.await
.unwrap();
store
.store_lesson("user1", "risk", "Trading risk rule", "my-trader")
.await
.unwrap();
let all_lessons = store.get_lessons("user1", Some("my-trader")).await.unwrap();
assert_eq!(
all_lessons.len(),
2,
"same domain, different projects = separate"
);
store
.store_lesson("user1", "risk", "Updated trading risk", "my-trader")
.await
.unwrap();
let updated = store.get_lessons("user1", Some("my-trader")).await.unwrap();
assert_eq!(
updated.len(),
3,
"different rule text creates new row (multi-lesson)"
);
let trading_rules: Vec<&str> = updated
.iter()
.filter(|l| l.2 == "my-trader")
.map(|l| l.1.as_str())
.collect();
assert!(trading_rules.contains(&"Trading risk rule"));
assert!(trading_rules.contains(&"Updated trading risk"));
}
#[tokio::test]
async fn test_lessons_multi_per_domain() {
let store = test_store().await;
store
.store_lesson("user1", "trading", "Always set stop-losses", "")
.await
.unwrap();
store
.store_lesson("user1", "trading", "Never risk more than 2%", "")
.await
.unwrap();
store
.store_lesson("user1", "trading", "Check volume before entry", "")
.await
.unwrap();
let lessons = store.get_lessons("user1", None).await.unwrap();
assert_eq!(lessons.len(), 3, "all 3 distinct rules should be stored");
let rules: Vec<&str> = lessons.iter().map(|l| l.1.as_str()).collect();
assert!(rules.contains(&"Always set stop-losses"));
assert!(rules.contains(&"Never risk more than 2%"));
assert!(rules.contains(&"Check volume before entry"));
}
#[tokio::test]
async fn test_lessons_content_dedup() {
let store = test_store().await;
store
.store_lesson("user1", "trading", "Always set stop-losses", "")
.await
.unwrap();
store
.store_lesson("user1", "trading", "Always set stop-losses", "")
.await
.unwrap();
let lessons = store.get_lessons("user1", None).await.unwrap();
assert_eq!(
lessons.len(),
1,
"duplicate rule text should not create new row"
);
let (occurrences,): (i64,) = sqlx::query_as(
"SELECT occurrences FROM lessons WHERE sender_id = 'user1' AND domain = 'trading'",
)
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(occurrences, 2, "occurrences should be 2 after dedup");
}
#[tokio::test]
async fn test_lessons_cap_enforcement() {
let store = test_store().await;
for i in 0..12 {
store
.store_lesson("user1", "trading", &format!("Rule number {i}"), "")
.await
.unwrap();
}
let lessons = store.get_lessons("user1", None).await.unwrap();
assert_eq!(
lessons.len(),
10,
"cap should prune to 10 per (sender, domain, project)"
);
let rules: Vec<&str> = lessons.iter().map(|l| l.1.as_str()).collect();
assert!(
!rules.contains(&"Rule number 0"),
"oldest rule should be pruned"
);
assert!(
!rules.contains(&"Rule number 1"),
"second-oldest rule should be pruned"
);
assert!(
rules.contains(&"Rule number 11"),
"newest rule should remain"
);
}
#[tokio::test]
async fn test_lessons_cap_project_isolation() {
let store = test_store().await;
for i in 0..12 {
store
.store_lesson(
"user1",
"trading",
&format!("Project A rule {i}"),
"project-a",
)
.await
.unwrap();
}
for i in 0..3 {
store
.store_lesson(
"user1",
"trading",
&format!("Project B rule {i}"),
"project-b",
)
.await
.unwrap();
}
let a_lessons: Vec<(String, String, String)> = sqlx::query_as(
"SELECT domain, rule, project FROM lessons \
WHERE sender_id = 'user1' AND project = 'project-a'",
)
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(a_lessons.len(), 10, "project A capped at 10");
let b_lessons: Vec<(String, String, String)> = sqlx::query_as(
"SELECT domain, rule, project FROM lessons \
WHERE sender_id = 'user1' AND project = 'project-b'",
)
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(
b_lessons.len(),
3,
"project B unaffected by project A's cap"
);
}
#[tokio::test]
async fn test_tasks_project_tag() {
let store = test_store().await;
store
.create_task(
"api",
"user1",
"chat1",
"General reminder",
"2099-12-31T00:00:00",
None,
"reminder",
"",
)
.await
.unwrap();
store
.create_task(
"api",
"user1",
"chat1",
"Check BTC",
"2020-01-01T00:00:00",
None,
"action",
"my-trader",
)
.await
.unwrap();
let tasks = store.get_tasks_for_sender("user1").await.unwrap();
assert_eq!(tasks.len(), 2);
let general = tasks.iter().find(|t| t.1 == "General reminder").unwrap();
assert_eq!(general.5, "");
let project = tasks.iter().find(|t| t.1 == "Check BTC").unwrap();
assert_eq!(project.5, "my-trader");
let due = store.get_due_tasks().await.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].description, "Check BTC");
assert_eq!(due[0].project, "my-trader");
}
#[tokio::test]
async fn test_get_all_lessons_project_filter() {
let store = test_store().await;
store
.store_lesson("user1", "comms", "Be clear", "")
.await
.unwrap();
store
.store_lesson("user2", "trading", "Check volume", "my-trader")
.await
.unwrap();
let all = store.get_all_lessons(None).await.unwrap();
assert_eq!(all.len(), 2);
let filtered = store.get_all_lessons(Some("my-trader")).await.unwrap();
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].2, "my-trader", "project first");
assert_eq!(filtered[1].2, "", "general second");
}
#[tokio::test]
async fn test_get_all_facts_by_key() {
let store = test_store().await;
store
.store_fact("user1", "active_project", "my-trader")
.await
.unwrap();
store
.store_fact("user2", "active_project", "my-trader")
.await
.unwrap();
store.store_fact("user3", "name", "Charlie").await.unwrap();
let active = store.get_all_facts_by_key("active_project").await.unwrap();
assert_eq!(active.len(), 2);
assert!(active.iter().all(|(_, v)| v == "my-trader"));
}
#[tokio::test]
async fn test_migration_existing_data_gets_empty_project() {
let store = test_store().await;
store
.store_outcome("user1", "test", 1, "lesson", "conversation", "")
.await
.unwrap();
store
.store_lesson("user1", "test", "rule", "")
.await
.unwrap();
let outcomes = store.get_recent_outcomes("user1", 10, None).await.unwrap();
assert_eq!(outcomes.len(), 1);
let lessons = store.get_lessons("user1", None).await.unwrap();
assert_eq!(lessons.len(), 1);
assert_eq!(lessons[0].2, "", "default project should be empty string");
}
#[tokio::test]
async fn test_store_and_get_session() {
let store = test_store().await;
let sid = store.get_session("api", "user1", "").await.unwrap();
assert!(sid.is_none());
store
.store_session("api", "user1", "", "session-abc")
.await
.unwrap();
let sid = store.get_session("api", "user1", "").await.unwrap();
assert_eq!(sid, Some("session-abc".to_string()));
}
#[tokio::test]
async fn test_session_upsert() {
let store = test_store().await;
store
.store_session("api", "user1", "", "session-1")
.await
.unwrap();
store
.store_session("api", "user1", "", "session-2")
.await
.unwrap();
let sid = store.get_session("api", "user1", "").await.unwrap();
assert_eq!(sid, Some("session-2".to_string()), "upsert should update");
}
#[tokio::test]
async fn test_session_project_isolation() {
let store = test_store().await;
store
.store_session("api", "user1", "", "personal-session")
.await
.unwrap();
store
.store_session("api", "user1", "trader", "trader-session")
.await
.unwrap();
let personal = store.get_session("api", "user1", "").await.unwrap();
assert_eq!(personal, Some("personal-session".to_string()));
let trader = store.get_session("api", "user1", "trader").await.unwrap();
assert_eq!(trader, Some("trader-session".to_string()));
}
#[tokio::test]
async fn test_clear_session() {
let store = test_store().await;
store
.store_session("api", "user1", "trader", "session-x")
.await
.unwrap();
store.clear_session("api", "user1", "trader").await.unwrap();
let sid = store.get_session("api", "user1", "trader").await.unwrap();
assert!(sid.is_none(), "session should be cleared");
}
#[tokio::test]
async fn test_clear_all_sessions_for_sender() {
let store = test_store().await;
store.store_session("api", "user1", "", "s1").await.unwrap();
store
.store_session("api", "user1", "trader", "s2")
.await
.unwrap();
store
.store_session("slack", "user1", "", "s3")
.await
.unwrap();
store.clear_all_sessions_for_sender("user1").await.unwrap();
assert!(store
.get_session("api", "user1", "")
.await
.unwrap()
.is_none());
assert!(store
.get_session("api", "user1", "trader")
.await
.unwrap()
.is_none());
assert!(store
.get_session("slack", "user1", "")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn test_conversation_project_isolation() {
let store = test_store().await;
let personal = store
.get_or_create_conversation("api", "user1", "")
.await
.unwrap();
let trader = store
.get_or_create_conversation("api", "user1", "trader")
.await
.unwrap();
assert_ne!(
personal, trader,
"different projects should get different conversations"
);
let personal2 = store
.get_or_create_conversation("api", "user1", "")
.await
.unwrap();
assert_eq!(
personal, personal2,
"same project should return same conversation"
);
}
#[tokio::test]
async fn test_close_current_conversation_project_scoped() {
let store = test_store().await;
let _personal = store
.get_or_create_conversation("api", "user1", "")
.await
.unwrap();
let _trader = store
.get_or_create_conversation("api", "user1", "trader")
.await
.unwrap();
let closed = store
.close_current_conversation("api", "user1", "trader")
.await
.unwrap();
assert!(closed, "should close trader conversation");
let personal_again = store
.get_or_create_conversation("api", "user1", "")
.await
.unwrap();
assert_eq!(
personal_again, _personal,
"personal conversation should still be active"
);
let trader_new = store
.get_or_create_conversation("api", "user1", "trader")
.await
.unwrap();
assert_ne!(
trader_new, _trader,
"closed trader should create new conversation"
);
}
#[tokio::test]
async fn test_find_idle_conversations_includes_project() {
let store = test_store().await;
sqlx::query(
"INSERT INTO conversations (id, channel, sender_id, project, status, last_activity) \
VALUES ('old1', 'api', 'user1', 'trader', 'active', datetime('now', '-3 hours'))",
)
.execute(store.pool())
.await
.unwrap();
let idle = store.find_idle_conversations().await.unwrap();
assert_eq!(idle.len(), 1);
assert_eq!(idle[0].0, "old1");
assert_eq!(idle[0].3, "trader", "should include project field");
}
#[tokio::test]
async fn test_lessons_dedup_reorders_by_updated_at() {
let store = test_store().await;
store
.store_lesson("user1", "cooking", "Rule A", "")
.await
.unwrap();
store
.store_lesson("user1", "cooking", "Rule B", "")
.await
.unwrap();
store
.store_lesson("user1", "cooking", "Rule C", "")
.await
.unwrap();
sqlx::query("UPDATE lessons SET updated_at = '2026-01-01 00:00:00' WHERE rule = 'Rule A'")
.execute(store.pool())
.await
.unwrap();
sqlx::query("UPDATE lessons SET updated_at = '2026-01-01 00:01:00' WHERE rule = 'Rule B'")
.execute(store.pool())
.await
.unwrap();
sqlx::query("UPDATE lessons SET updated_at = '2026-01-01 00:02:00' WHERE rule = 'Rule C'")
.execute(store.pool())
.await
.unwrap();
let before = store.get_lessons("user1", None).await.unwrap();
assert_eq!(before[0].1, "Rule C", "newest should be first");
assert_eq!(before[2].1, "Rule A", "oldest should be last");
store
.store_lesson("user1", "cooking", "Rule A", "")
.await
.unwrap();
let after = store.get_lessons("user1", None).await.unwrap();
assert_eq!(after.len(), 3, "dedup should not create a 4th row");
assert_eq!(
after[0].1, "Rule A",
"reinforced lesson should be first (most recent updated_at)"
);
}
#[tokio::test]
async fn test_lessons_reinforced_survives_cap() {
let store = test_store().await;
for i in 0..10 {
store
.store_lesson("user1", "trading", &format!("Rule {i}"), "")
.await
.unwrap();
sqlx::query(&format!(
"UPDATE lessons SET updated_at = '2026-01-01 00:{:02}:00' WHERE rule = 'Rule {}'",
i, i
))
.execute(store.pool())
.await
.unwrap();
}
store
.store_lesson("user1", "trading", "Rule 0", "")
.await
.unwrap();
store
.store_lesson("user1", "trading", "Rule 10", "")
.await
.unwrap();
let lessons = store.get_lessons("user1", None).await.unwrap();
assert_eq!(lessons.len(), 10, "cap should keep 10");
let rules: Vec<&str> = lessons.iter().map(|l| l.1.as_str()).collect();
assert!(
rules.contains(&"Rule 0"),
"reinforced Rule 0 should survive cap (its updated_at was bumped)"
);
assert!(
!rules.contains(&"Rule 1"),
"Rule 1 (now oldest) should be pruned"
);
assert!(
rules.contains(&"Rule 10"),
"newest Rule 10 should be present"
);
}
#[tokio::test]
async fn test_lessons_dedup_cross_project_isolation() {
let store = test_store().await;
store
.store_lesson("user1", "risk", "Never risk more than 2%", "project-a")
.await
.unwrap();
store
.store_lesson("user1", "risk", "Never risk more than 2%", "project-b")
.await
.unwrap();
let a: Vec<(String,)> = sqlx::query_as(
"SELECT rule FROM lessons WHERE sender_id = 'user1' AND project = 'project-a'",
)
.fetch_all(store.pool())
.await
.unwrap();
let b: Vec<(String,)> = sqlx::query_as(
"SELECT rule FROM lessons WHERE sender_id = 'user1' AND project = 'project-b'",
)
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(a.len(), 1, "project-a should have its own row");
assert_eq!(b.len(), 1, "project-b should have its own row");
let (occ_a,): (i64,) = sqlx::query_as(
"SELECT occurrences FROM lessons WHERE sender_id = 'user1' AND project = 'project-a'",
)
.fetch_one(store.pool())
.await
.unwrap();
let (occ_b,): (i64,) = sqlx::query_as(
"SELECT occurrences FROM lessons WHERE sender_id = 'user1' AND project = 'project-b'",
)
.fetch_one(store.pool())
.await
.unwrap();
assert_eq!(occ_a, 1, "project-a occurrences should be 1");
assert_eq!(occ_b, 1, "project-b occurrences should be 1");
}
#[tokio::test]
async fn test_lessons_dedup_cross_sender_isolation() {
let store = test_store().await;
store
.store_lesson("user1", "cooking", "Salt the water", "")
.await
.unwrap();
store
.store_lesson("user2", "cooking", "Salt the water", "")
.await
.unwrap();
let u1: Vec<(String,)> =
sqlx::query_as("SELECT rule FROM lessons WHERE sender_id = 'user1' AND domain = 'cooking'")
.fetch_all(store.pool())
.await
.unwrap();
let u2: Vec<(String,)> =
sqlx::query_as("SELECT rule FROM lessons WHERE sender_id = 'user2' AND domain = 'cooking'")
.fetch_all(store.pool())
.await
.unwrap();
assert_eq!(u1.len(), 1, "user1 should have its own row");
assert_eq!(u2.len(), 1, "user2 should have its own row");
}
#[tokio::test]
async fn test_get_lessons_limit_50() {
let store = test_store().await;
for i in 0..11 {
for domain in &["a", "b", "c", "d", "e"] {
store
.store_lesson("user1", domain, &format!("Rule {domain}-{i}"), "")
.await
.unwrap();
}
}
for i in 0..5 {
store
.store_lesson("user1", "f", &format!("Rule f-{i}"), "")
.await
.unwrap();
}
let lessons = store.get_lessons("user1", None).await.unwrap();
assert!(
lessons.len() <= 50,
"get_lessons should return at most 50, got {}",
lessons.len()
);
}
#[tokio::test]
async fn test_get_all_lessons_limit_50() {
let store = test_store().await;
for user in &["u1", "u2", "u3", "u4", "u5", "u6"] {
for i in 0..10 {
store
.store_lesson(user, "general", &format!("Rule {user}-{i}"), "")
.await
.unwrap();
}
}
let all = store.get_all_lessons(None).await.unwrap();
assert!(
all.len() <= 50,
"get_all_lessons should return at most 50, got {}",
all.len()
);
}
#[test]
fn test_build_system_prompt_recall_multibyte_truncation() {
use super::context::{build_system_prompt, SystemPromptContext};
let long_cjk = "\u{4e2d}".repeat(100);
let recall = vec![(
"user".to_string(),
long_cjk,
"2026-01-01 12:00:00".to_string(),
)];
let result = build_system_prompt(&SystemPromptContext {
base_rules: "base rules",
facts: &[],
summaries: &[],
recall: &recall,
pending_tasks: &[],
outcomes: &[],
lessons: &[],
language: "en",
onboarding_hint: None,
});
assert!(result.contains("Related past context"));
}
#[tokio::test]
async fn test_search_messages_with_fts5_operators() {
let store = test_store().await;
let conv_id = store
.get_or_create_conversation("api", "user1", "default")
.await
.unwrap();
let incoming = Request::text("user1", "the server is NOT working properly");
let response = kernex_core::message::Response {
text: "I will investigate".to_string(),
metadata: kernex_core::message::CompletionMeta {
provider_used: "test".to_string(),
tokens_used: None,
processing_time_ms: 0,
model: None,
session_id: None,
..Default::default()
},
};
store
.store_exchange("api", &incoming, &response, "default")
.await
.unwrap();
let result = store
.search_messages("NOT working", &conv_id, "user1", 5)
.await;
assert!(
result.is_ok(),
"FTS5 operators in query should not cause an error: {:?}",
result.err()
);
let result = store
.search_messages("error (crash)", &conv_id, "user1", 5)
.await;
assert!(
result.is_ok(),
"FTS5 parentheses in query should not cause an error: {:?}",
result.err()
);
let result = store.search_messages("work*", &conv_id, "user1", 5).await;
assert!(
result.is_ok(),
"FTS5 asterisk in query should not cause an error: {:?}",
result.err()
);
let result = store
.search_messages(r#"say "hello world""#, &conv_id, "user1", 5)
.await;
assert!(
result.is_ok(),
"FTS5 quotes in query should not cause an error: {:?}",
result.err()
);
}