From 4cb4df78901dc0daac21e509462e293870d0269e Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 13 Jun 2026 07:39:50 +0000 Subject: [PATCH] refactor(telegram): remove low-value read-only commands Drop the /status, /stats, /last, /today, and /recent commands. They mostly re-displayed entries the user can already scroll to in chat, or exposed vanity counters, and carried a disproportionate amount of support code. Removes the commands from the Telegram adapter and JournalCommand enum, their service handlers, and all code left dead only by their removal: the status report module, the JournalService status/embedding-config builder methods and their app.rs wiring, the unused fetch_last_for_conversation and stats repository methods, and the JournalStats type. Tests that relied on the removed commands to verify behaviour (undo isolation, multiuser routing) now assert against the database directly. Co-Authored-By: Claude Fable 5 --- src/adapters/telegram.rs | 62 +--- src/app.rs | 15 +- src/journal/command.rs | 9 - src/journal/entry.rs | 7 - src/journal/mod.rs | 1 - src/journal/registry.rs | 12 +- src/journal/repository.rs | 53 +--- src/journal/repository_tests.rs | 109 +------- src/journal/responses.rs | 152 ---------- src/journal/review/wiring.rs | 57 +--- src/journal/service/commands.rs | 141 +--------- src/journal/service/mod.rs | 39 +-- src/journal/service/tests.rs | 481 +------------------------------- src/journal/status.rs | 45 --- tests/multiuser_tests.rs | 98 ++++--- 15 files changed, 89 insertions(+), 1192 deletions(-) delete mode 100644 src/journal/status.rs diff --git a/src/adapters/telegram.rs b/src/adapters/telegram.rs index 01056349..9815e41a 100644 --- a/src/adapters/telegram.rs +++ b/src/adapters/telegram.rs @@ -10,7 +10,7 @@ use tracing::{error, info, warn}; use crate::{ handler::MessageHandler, - journal::command::{DEFAULT_RECENT_LIMIT, JournalCommand, JournalCommandRequest}, + journal::command::{JournalCommand, JournalCommandRequest}, journal::transfer::{TransferError, TransferService}, messages::{IncomingMessage, MessageSource}, tokens::TokenIssuer, @@ -126,22 +126,12 @@ enum Command { Start, #[command(description = "show commands")] Help, - #[command(description = "show latest entry")] - Last, #[command(description = "delete latest entry")] Undo, - #[command(description = "show recent entries (optionally how many)")] - Recent(String), - #[command(description = "show today's entries")] - Today, #[command(description = "show daily review")] DayReview, #[command(description = "show last week's review")] WeekReview, - #[command(description = "show journal stats")] - Stats, - #[command(description = "show bot status")] - Status, #[command(description = "search entries by meaning")] Search(String), #[command(description = "create or rotate your MCP access token (/token revoke to disable)")] @@ -168,29 +158,9 @@ fn dispatch_for(command: Command) -> Dispatch { match command { Command::Start => Dispatch::Journal(JournalCommand::Start), Command::Help => Dispatch::Help, - Command::Last => Dispatch::Journal(JournalCommand::Last), Command::Undo => Dispatch::Journal(JournalCommand::Undo), - Command::Recent(argument) => { - let argument = argument.trim(); - let command = if argument.is_empty() { - JournalCommand::Recent { - requested_limit: DEFAULT_RECENT_LIMIT, - } - } else { - match argument.parse::() { - Ok(limit) if limit > 0 => JournalCommand::Recent { - requested_limit: limit, - }, - _ => JournalCommand::RecentUsage, - } - }; - Dispatch::Journal(command) - } - Command::Today => Dispatch::Journal(JournalCommand::Today), Command::DayReview => Dispatch::Journal(JournalCommand::DayReviewLast), Command::WeekReview => Dispatch::Journal(JournalCommand::WeekReviewLast), - Command::Stats => Dispatch::Journal(JournalCommand::Stats), - Command::Status => Dispatch::Journal(JournalCommand::Status), Command::Search(query) => { let query = query.trim(); let command = if query.is_empty() { @@ -697,11 +667,7 @@ mod tests { #[test] fn parse_journal_commands() { assert_eq!(journal("/start"), Some(JournalCommand::Start)); - assert_eq!(journal("/last"), Some(JournalCommand::Last)); assert_eq!(journal("/undo"), Some(JournalCommand::Undo)); - assert_eq!(journal("/today"), Some(JournalCommand::Today)); - assert_eq!(journal("/stats"), Some(JournalCommand::Stats)); - assert_eq!(journal("/status"), Some(JournalCommand::Status)); assert_eq!(journal("/day_review"), Some(JournalCommand::DayReviewLast)); assert_eq!( journal("/week_review"), @@ -711,11 +677,10 @@ mod tests { #[test] fn parse_strips_bot_name_suffix() { - assert_eq!(journal("/last@mybot"), Some(JournalCommand::Last)); - assert_eq!(journal("/status@mybot"), Some(JournalCommand::Status)); + assert_eq!(journal("/undo@mybot"), Some(JournalCommand::Undo)); assert_eq!( - journal("/recent@mybot 3"), - Some(JournalCommand::Recent { requested_limit: 3 }) + journal("/day_review@mybot"), + Some(JournalCommand::DayReviewLast) ); assert_eq!( journal("/search@mybot something"), @@ -725,23 +690,6 @@ mod tests { ); } - #[test] - fn parse_recent_command_arguments() { - assert_eq!( - journal("/recent"), - Some(JournalCommand::Recent { - requested_limit: DEFAULT_RECENT_LIMIT - }) - ); - assert_eq!( - journal("/recent 5"), - Some(JournalCommand::Recent { requested_limit: 5 }) - ); - assert_eq!(journal("/recent abc"), Some(JournalCommand::RecentUsage)); - assert_eq!(journal("/recent 0"), Some(JournalCommand::RecentUsage)); - assert_eq!(journal("/recent -3"), Some(JournalCommand::RecentUsage)); - } - #[test] fn parse_search_command_arguments() { assert_eq!( @@ -796,7 +744,7 @@ mod tests { registered.command ); } - assert!(help.contains("/recent")); + assert!(help.contains("/search")); assert!(help.contains("/token")); assert!(help.contains("/export")); } diff --git a/src/app.rs b/src/app.rs index c4ff099c..4d4af931 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,7 +27,6 @@ use crate::{ }, search::SemanticSearchService, service::JournalService, - status::EmbeddingStatusConfig, week_review::{build_weekly_review_service, configure_weekly_review}, }, prompts::{PromptKey, PromptRepository, PromptSource}, @@ -253,7 +252,6 @@ pub(crate) fn build_journal_service( pool: SqlitePool, prompt_repository: &PromptRepository, config: &ServeConfig, - delivery_configured: bool, ) -> Result> { let mut journal_service = JournalService::new(JournalRepository::new(pool.clone())); @@ -278,14 +276,9 @@ pub(crate) fn build_journal_service( config.weekly_review.clone(), )?; - if delivery_configured { - journal_service = journal_service.with_daily_review_delivery_configured(); - } - if let Some(api_key) = config.openai_api_key() { - let cfg = config.embedding.clone(); let embedder = - RigOpenAiEmbedder::from_optional_api_key(cfg.clone(), Some(api_key.to_string())) + RigOpenAiEmbedder::from_optional_api_key(config.embedding.clone(), Some(api_key.to_string())) .map_err(|error| { warn!( error = %error, @@ -300,7 +293,6 @@ pub(crate) fn build_journal_service( crate::journal::review::embedding_repository::SqliteDailyReviewEmbeddingRepository::new( pool.clone(), ); - let status_config = EmbeddingStatusConfig { model: cfg.model }; let search = SemanticSearchService::new( embedding_repository.clone(), Arc::clone(&embedder), @@ -314,10 +306,7 @@ pub(crate) fn build_journal_service( journal_service = journal_service.with_search(search); journal_service = journal_service.with_daily_review_search(review_search); - journal_service = - journal_service.with_capture_embedding(embedding_repository.clone(), embedder); - journal_service = journal_service.with_embedding_status_config(status_config); - journal_service = journal_service.with_pending_embedding_counter(embedding_repository); + journal_service = journal_service.with_capture_embedding(embedding_repository, embedder); } else { warn!("OPENAI_API_KEY is not set; semantic search and embeddings are disabled"); } diff --git a/src/journal/command.rs b/src/journal/command.rs index 1da83a75..f8be49c2 100644 --- a/src/journal/command.rs +++ b/src/journal/command.rs @@ -2,9 +2,6 @@ use chrono::{DateTime, Utc}; use crate::messages::MessageSource; -pub const DEFAULT_RECENT_LIMIT: u32 = 10; -pub const MAX_RECENT_LIMIT: u32 = 50; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct JournalCommandRequest { pub source: MessageSource, @@ -16,13 +13,7 @@ pub struct JournalCommandRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub enum JournalCommand { Start, - Last, Undo, - Recent { requested_limit: u32 }, - RecentUsage, - Today, - Stats, - Status, DayReviewLast, WeekReviewLast, Search { query: String }, diff --git a/src/journal/entry.rs b/src/journal/entry.rs index 3d45bbe9..c0325798 100644 --- a/src/journal/entry.rs +++ b/src/journal/entry.rs @@ -23,10 +23,3 @@ impl AsRef for StoredJournalEntry { &self.entry } } - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct JournalStats { - pub total_entries: i64, - pub entries_today: i64, - pub latest_received_at: Option>, -} diff --git a/src/journal/mod.rs b/src/journal/mod.rs index a1854200..0875e90f 100644 --- a/src/journal/mod.rs +++ b/src/journal/mod.rs @@ -9,7 +9,6 @@ pub(crate) mod responses; pub mod review; pub mod search; pub mod service; -pub mod status; pub mod store; pub mod transfer; pub mod week_review; diff --git a/src/journal/registry.rs b/src/journal/registry.rs index 13be5666..263f18e5 100644 --- a/src/journal/registry.rs +++ b/src/journal/registry.rs @@ -147,13 +147,11 @@ impl JournalServiceRegistry { let prompt_repository = PromptRepository::new(pool.clone()); // Build the JournalService for this pool - let service = crate::app::build_journal_service( - pool.clone(), - &prompt_repository, - &self.serve_config, - self.serve_config.daily_review_delivery.enabled, - ) - .map_err(|e| -> Box { e.to_string().into() })?; + let service = + crate::app::build_journal_service(pool.clone(), &prompt_repository, &self.serve_config) + .map_err(|e| -> Box { + e.to_string().into() + })?; guard.insert(chat_id.to_string(), service.clone()); Ok(service) diff --git a/src/journal/repository.rs b/src/journal/repository.rs index 6fe02f47..061c2791 100644 --- a/src/journal/repository.rs +++ b/src/journal/repository.rs @@ -5,7 +5,7 @@ use sqlx::{Row, SqlitePool, sqlite::SqliteRow}; use crate::messages::{IncomingMessage, MessageSource}; -use super::entry::{JournalEntry, JournalStats, StoredJournalEntry}; +use super::entry::{JournalEntry, StoredJournalEntry}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct JournalConversation { @@ -152,32 +152,6 @@ impl JournalRepository { .collect()) } - pub async fn fetch_last_for_conversation( - &self, - source: &MessageSource, - source_conversation_id: &str, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - r#" - SELECT id, raw_text, received_at - FROM journal_entries - WHERE source = ? - AND source_conversation_id = ? - ORDER BY received_at DESC, id DESC - LIMIT 1 - "#, - ) - .bind(source.to_string()) - .bind(source_conversation_id) - .fetch_optional(&self.pool) - .await?; - - Ok(row.map(|row| StoredJournalEntry { - id: row.get("id"), - entry: map_entry(row), - })) - } - pub async fn delete_last_for_conversation( &self, source: &MessageSource, @@ -504,29 +478,4 @@ impl JournalRepository { }) .collect()) } - - pub async fn stats(&self, today: NaiveDate) -> Result { - let start = Utc.from_utc_datetime(&today.and_hms_opt(0, 0, 0).unwrap()); - let end = start + Duration::days(1); - - let row = sqlx::query( - r#" - SELECT - COUNT(*) AS total_entries, - COALESCE(SUM(CASE WHEN received_at >= ? AND received_at < ? THEN 1 ELSE 0 END), 0) AS entries_today, - MAX(received_at) AS latest_received_at - FROM journal_entries - "#, - ) - .bind(start) - .bind(end) - .fetch_one(&self.pool) - .await?; - - Ok(JournalStats { - total_entries: row.get("total_entries"), - entries_today: row.get("entries_today"), - latest_received_at: row.get("latest_received_at"), - }) - } } diff --git a/src/journal/repository_tests.rs b/src/journal/repository_tests.rs index a355ff9d..2d46e192 100644 --- a/src/journal/repository_tests.rs +++ b/src/journal/repository_tests.rs @@ -241,63 +241,7 @@ async fn fetch_all_returns_empty_when_no_entries() { } #[tokio::test] -async fn fetch_last_for_conversation_returns_latest_entry_for_current_conversation() { - let repo = setup().await; - repo.store(&incoming_for_conversation( - "42", - "1", - "current old", - at(10, 0), - )) - .await - .unwrap(); - repo.store(&incoming_for_conversation( - "42", - "2", - "current new", - at(11, 0), - )) - .await - .unwrap(); - repo.store(&incoming_for_conversation( - "99", - "3", - "other conversation", - at(12, 0), - )) - .await - .unwrap(); - - let entry = repo - .fetch_last_for_conversation(&MessageSource::Telegram, "42") - .await - .unwrap() - .unwrap(); - - assert_eq!(entry.entry.text, "current new"); -} - -#[tokio::test] -async fn fetch_last_for_conversation_breaks_timestamp_ties_by_id() { - let repo = setup().await; - repo.store(&incoming("1", "first inserted", at(10, 0))) - .await - .unwrap(); - repo.store(&incoming("2", "second inserted", at(10, 0))) - .await - .unwrap(); - - let entry = repo - .fetch_last_for_conversation(&MessageSource::Telegram, "42") - .await - .unwrap() - .unwrap(); - - assert_eq!(entry.entry.text, "second inserted"); -} - -#[tokio::test] -async fn delete_last_for_conversation_deletes_same_entry_selected_by_fetch_last() { +async fn delete_last_for_conversation_deletes_newest_entry_breaking_ties_by_id() { let repo = setup().await; repo.store(&incoming("1", "first inserted", at(10, 0))) .await @@ -306,11 +250,6 @@ async fn delete_last_for_conversation_deletes_same_entry_selected_by_fetch_last( .await .unwrap(); - let fetched = repo - .fetch_last_for_conversation(&MessageSource::Telegram, "42") - .await - .unwrap() - .unwrap(); let deleted = repo .delete_last_for_conversation(&MessageSource::Telegram, "42") .await @@ -318,7 +257,6 @@ async fn delete_last_for_conversation_deletes_same_entry_selected_by_fetch_last( .unwrap(); let remaining = repo.fetch_recent(10).await.unwrap(); - assert_eq!(deleted.id, fetched.id); assert_eq!(deleted.entry.text, "second inserted"); assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].entry.text, "first inserted"); @@ -339,14 +277,11 @@ async fn delete_last_for_conversation_does_not_delete_other_conversations() { .await .unwrap() .unwrap(); - let other = repo - .fetch_last_for_conversation(&MessageSource::Telegram, "99") - .await - .unwrap() - .unwrap(); + let remaining = repo.fetch_recent(10).await.unwrap(); assert_eq!(deleted.entry.text, "current"); - assert_eq!(other.entry.text, "other"); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].entry.text, "other"); } #[tokio::test] @@ -857,39 +792,3 @@ async fn fetch_by_ids_returns_empty_when_no_ids_match() { assert!(rows.is_empty()); } - -#[tokio::test] -async fn stats_returns_counts_and_latest_timestamp_for_user() { - let repo = setup().await; - - repo.store(&incoming("1", "first", at(10, 0))) - .await - .unwrap(); - repo.store(&incoming( - "2", - "tomorrow", - Utc.with_ymd_and_hms(2026, 4, 29, 9, 0, 0).unwrap(), - )) - .await - .unwrap(); - - let stats = repo.stats(date()).await.unwrap(); - - assert_eq!(stats.total_entries, 2); - assert_eq!(stats.entries_today, 1); - assert_eq!( - stats.latest_received_at, - Some(Utc.with_ymd_and_hms(2026, 4, 29, 9, 0, 0).unwrap()) - ); -} - -#[tokio::test] -async fn stats_returns_zeroes_when_journal_has_no_entries() { - let repo = setup().await; - - let stats = repo.stats(date()).await.unwrap(); - - assert_eq!(stats.total_entries, 0); - assert_eq!(stats.entries_today, 0); - assert_eq!(stats.latest_received_at, None); -} diff --git a/src/journal/responses.rs b/src/journal/responses.rs index 356937a9..14c99e31 100644 --- a/src/journal/responses.rs +++ b/src/journal/responses.rs @@ -1,12 +1,6 @@ use chrono::NaiveDate; -use super::embedding::SUPPORTED_EMBEDDING_DIMENSIONS; -use super::entry::{JournalEntry, JournalStats}; use super::review::DailyReview; -use super::status::{ - DailyReviewDeliveryStatus, DailyReviewGenerationStatus, DailyReviewStatus, EmbeddingStatus, - SemanticSearchStatus, StatusReport, -}; use super::week_review::WeeklyReview; pub(super) fn message_saved_response() -> String { @@ -17,22 +11,10 @@ pub(super) fn start_response() -> String { "Froid is your private journal. Send me any text message and I will store it for you.\n\nI use AI to help you find meaning in your entries and provide daily and weekly reviews of your thoughts.\n\nUse /help to see all available commands.".to_string() } -pub(super) fn recent_usage_response() -> String { - "Usage: /recent [number]\n\nExamples:\n/recent\n/recent 5".to_string() -} - pub(super) fn search_usage_response() -> String { "Usage: /search \n\nExamples:\n/search anxiety before meetings".to_string() } -pub(super) fn no_entries_response() -> String { - "No journal entries found.".to_string() -} - -pub(super) fn no_last_entry_response() -> String { - "No journal entry found.".to_string() -} - pub(super) fn no_entry_to_delete_response() -> String { "No journal entry to delete.".to_string() } @@ -41,10 +23,6 @@ pub(super) fn deleted_last_entry_response() -> String { "Deleted last entry.".to_string() } -pub(super) fn no_entries_today_response() -> String { - "No journal entries found for today.".to_string() -} - pub(super) fn daily_review_unavailable_response() -> String { "Daily review generation is not configured yet.".to_string() } @@ -85,133 +63,3 @@ pub(super) fn weekly_review_not_available_response(week_start: NaiveDate) -> Str week_start.format("%Y-%m-%d") ) } - -pub(super) fn stats_response(stats: &JournalStats) -> String { - let latest = stats - .latest_received_at - .map(|timestamp| timestamp.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "none".to_string()); - - format!( - "Journal stats:\nTotal entries: {}\nEntries today: {}\nLatest entry: {}", - stats.total_entries, stats.entries_today, latest - ) -} - -pub(super) fn status_response(report: &StatusReport) -> String { - format!( - "Froid status\n\nJournal:\n- Total entries: {}\n- Entries today: {}\n\nEmbeddings:\n{}\n\nDaily review:\n{}", - report.journal.total_entries, - report.journal.entries_today, - format_embedding_status(&report.embeddings), - format_daily_review_status(&report.daily_review) - ) -} - -fn format_embedding_status(status: &EmbeddingStatus) -> String { - let semantic_search = match status.semantic_search { - SemanticSearchStatus::Enabled => "enabled", - SemanticSearchStatus::Unavailable => "unavailable", - }; - let model = status - .config - .as_ref() - .map(|config| config.model.as_str()) - .unwrap_or("unavailable"); - let dimensions = status - .config - .as_ref() - .map(|_| SUPPORTED_EMBEDDING_DIMENSIONS.to_string()) - .unwrap_or_else(|| "unavailable".to_string()); - let pending_embeddings = status - .pending_embeddings - .map(|count| count.to_string()) - .unwrap_or_else(|| "unavailable".to_string()); - - format!( - "- Semantic search: {semantic_search}\n- Model: {model}\n- Dimensions: {dimensions}\n- Pending embeddings: {pending_embeddings}" - ) -} - -fn format_daily_review_status(status: &DailyReviewStatus) -> String { - let generation = match status.generation { - DailyReviewGenerationStatus::Configured => "configured", - DailyReviewGenerationStatus::NotConfigured => "not configured", - }; - let delivery = match status.delivery { - DailyReviewDeliveryStatus::Configured => "configured", - DailyReviewDeliveryStatus::NotConfigured => "not configured", - }; - - let mut lines = vec![format!("- Generation: {generation}")]; - if let Some(prompt_version) = &status.prompt_version { - lines.push(format!("- Prompt: {prompt_version}")); - } - lines.push(format!("- Delivery: {delivery}")); - - lines.join("\n") -} - -pub(super) fn format_entries>(entries: &[T]) -> String { - entries - .iter() - .map(|e| { - let entry = e.as_ref(); - format!( - "{} - {}", - entry.received_at.format("%Y-%m-%d %H:%M"), - entry.text - ) - }) - .collect::>() - .join("\n") -} - -pub(super) fn format_last_entry(entry: &JournalEntry) -> String { - format!( - "Last entry:\n\n\"{}\"\n\nReceived at: {}\n\nUse /undo to delete it.", - entry.text, - entry.received_at.format("%Y-%m-%d %H:%M") - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::journal::entry::{JournalEntry, StoredJournalEntry}; - use chrono::{TimeZone, Utc}; - - fn entry(day: u32, text: &str) -> JournalEntry { - JournalEntry { - text: text.to_string(), - received_at: Utc.with_ymd_and_hms(2026, 4, day, 10, 0, 0).unwrap(), - } - } - - #[test] - fn format_entries_works_with_journal_entries() { - let entries = vec![entry(28, "first"), entry(28, "second")]; - let formatted = format_entries(&entries); - - assert!(formatted.contains("2026-04-28 10:00 - first")); - assert!(formatted.contains("2026-04-28 10:00 - second")); - } - - #[test] - fn format_entries_works_with_stored_journal_entries() { - let entries = vec![ - StoredJournalEntry { - id: "1".to_string(), - entry: entry(28, "first"), - }, - StoredJournalEntry { - id: "2".to_string(), - entry: entry(28, "second"), - }, - ]; - let formatted = format_entries(&entries); - - assert!(formatted.contains("2026-04-28 10:00 - first")); - assert!(formatted.contains("2026-04-28 10:00 - second")); - } -} diff --git a/src/journal/review/wiring.rs b/src/journal/review/wiring.rs index 00c84cd1..65729776 100644 --- a/src/journal/review/wiring.rs +++ b/src/journal/review/wiring.rs @@ -28,21 +28,12 @@ pub fn configure_daily_review( prompt_repository: &PromptRepository, config: DailyReviewRuntimeConfig, ) -> Result> { - let prompt_version = config - .prompt - .path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); let Some(daily_review_service) = build_daily_review_service(pool, prompt_repository, config)? else { return Ok(journal_service); }; - Ok(journal_service - .with_daily_review_runner(daily_review_service) - .with_daily_review_prompt_version(prompt_version)) + Ok(journal_service.with_daily_review_runner(daily_review_service)) } pub fn build_daily_review_service( @@ -223,52 +214,6 @@ mod tests { fs::remove_file(prompt_path).unwrap(); } - #[tokio::test] - async fn prompt_version_derived_from_filename_is_exposed_to_status() { - let prompt_path = temp_prompt_path("daily-review-v-test"); - fs::write(&prompt_path, "Prompt text").unwrap(); - let expected_version = prompt_path - .file_stem() - .unwrap() - .to_string_lossy() - .into_owned(); - let pool = setup_pool().await; - let prompts = PromptRepository::new(pool.clone()); - - let service = configure_daily_review( - JournalService::new(JournalRepository::new(pool.clone())), - pool, - &prompts, - DailyReviewRuntimeConfig { - openai_api_key: Some("test-api-key".to_string()), - review: ReviewConfig::default(), - prompt: DailyReviewPromptConfig { - path: prompt_path.clone(), - }, - }, - ) - .unwrap(); - - let response = service - .command(&JournalCommandRequest { - source: MessageSource::Telegram, - source_conversation_id: "42".to_string(), - received_at: Utc::now(), - command: JournalCommand::Status, - }) - .await - .unwrap(); - - assert!(response.text.contains("- Generation: configured")); - assert!( - response - .text - .contains(&format!("- Prompt: {expected_version}")) - ); - - fs::remove_file(prompt_path).unwrap(); - } - async fn setup_pool() -> SqlitePool { crate::database::test_pool().await } diff --git a/src/journal/service/commands.rs b/src/journal/service/commands.rs index 0115a320..f419478e 100644 --- a/src/journal/service/commands.rs +++ b/src/journal/service/commands.rs @@ -1,16 +1,14 @@ use chrono::{Duration, NaiveDate}; -use tracing::{error, warn}; +use tracing::error; use crate::{ journal::{ - command::{JournalCommand, JournalCommandRequest, MAX_RECENT_LIMIT}, + command::{JournalCommand, JournalCommandRequest}, responses::{ daily_review_not_available_for_date_response, daily_review_unavailable_response, - deleted_last_entry_response, format_daily_review_for_date, format_entries, - format_last_entry, format_weekly_review_for_week, no_entries_response, - no_entries_today_response, no_entry_to_delete_response, no_last_entry_response, - recent_usage_response, search_usage_response, start_response, stats_response, - status_response, weekly_review_not_available_response, + deleted_last_entry_response, format_daily_review_for_date, + format_weekly_review_for_week, no_entry_to_delete_response, search_usage_response, + start_response, weekly_review_not_available_response, weekly_review_unavailable_response, }, review::DailyReview, @@ -18,10 +16,6 @@ use crate::{ format_search_results, search_empty_response, search_error_response, search_unavailable_response, }, - status::{ - DailyReviewDeliveryStatus, DailyReviewGenerationStatus, DailyReviewStatus, - EmbeddingStatus, SemanticSearchStatus, StatusReport, - }, }, messages::OutgoingMessage, }; @@ -45,15 +39,7 @@ impl JournalService { JournalCommand::Start => Ok(OutgoingMessage { text: start_response(), }), - JournalCommand::Last => self.last(request).await, JournalCommand::Undo => self.undo(request).await, - JournalCommand::Recent { requested_limit } => self.recent(*requested_limit).await, - JournalCommand::RecentUsage => Ok(OutgoingMessage { - text: recent_usage_response(), - }), - JournalCommand::Today => self.today(request.received_at.date_naive()).await, - JournalCommand::Stats => self.stats(request.received_at.date_naive()).await, - JournalCommand::Status => self.status(request.received_at.date_naive()).await, JournalCommand::DayReviewLast => { Ok(self.day_review_last(request.received_at.date_naive()).await) } @@ -150,22 +136,6 @@ impl JournalService { } } - async fn last(&self, request: &JournalCommandRequest) -> Result { - let Some(entry) = self - .repository - .fetch_last_for_conversation(&request.source, &request.source_conversation_id) - .await? - else { - return Ok(OutgoingMessage { - text: no_last_entry_response(), - }); - }; - - Ok(OutgoingMessage { - text: format_last_entry(&entry.entry), - }) - } - async fn undo(&self, request: &JournalCommandRequest) -> Result { let Some(_) = self .store @@ -181,105 +151,4 @@ impl JournalService { text: deleted_last_entry_response(), }) } - - async fn recent(&self, limit: u32) -> Result { - let limit = limit.min(MAX_RECENT_LIMIT); - let entries = self.repository.fetch_recent(limit).await?; - - if entries.is_empty() { - return Ok(OutgoingMessage { - text: no_entries_response(), - }); - } - - Ok(OutgoingMessage { - text: format_entries(&entries), - }) - } - - async fn today(&self, date: chrono::NaiveDate) -> Result { - let entries = self.repository.fetch_today(date).await?; - - if entries.is_empty() { - return Ok(OutgoingMessage { - text: no_entries_today_response(), - }); - } - - Ok(OutgoingMessage { - text: format_entries(&entries), - }) - } - - async fn stats(&self, today: chrono::NaiveDate) -> Result { - let stats = self.repository.stats(today).await?; - - Ok(OutgoingMessage { - text: stats_response(&stats), - }) - } - - async fn status(&self, today: chrono::NaiveDate) -> Result { - let journal = self.repository.stats(today).await?; - let embeddings = self.embedding_status().await; - let daily_review = self.daily_review_status(); - - Ok(OutgoingMessage { - text: status_response(&StatusReport { - journal, - embeddings, - daily_review, - }), - }) - } - - async fn embedding_status(&self) -> EmbeddingStatus { - let semantic_search = if self.search.is_some() && self.embedding_status_config.is_some() { - SemanticSearchStatus::Enabled - } else { - SemanticSearchStatus::Unavailable - }; - - let pending_embeddings = match ( - self.embedding_status_config.as_ref(), - self.pending_embedding_counter.as_ref(), - ) { - (Some(config), Some(counter)) => { - match counter.count_entries_missing_embedding(&config.model).await { - Ok(count) => Some(count), - Err(error) => { - warn!(%error, "failed to count pending embeddings for status"); - None - } - } - } - _ => None, - }; - - EmbeddingStatus { - semantic_search, - config: self.embedding_status_config.clone(), - pending_embeddings, - } - } - - fn daily_review_status(&self) -> DailyReviewStatus { - let generation = if self.daily_review.is_some() { - DailyReviewGenerationStatus::Configured - } else { - DailyReviewGenerationStatus::NotConfigured - }; - - let delivery = if self.daily_review_delivery_configured { - DailyReviewDeliveryStatus::Configured - } else { - DailyReviewDeliveryStatus::NotConfigured - }; - - DailyReviewStatus { - generation, - prompt_version: self.daily_review_prompt_version.clone(), - delivery, - } - } } diff --git a/src/journal/service/mod.rs b/src/journal/service/mod.rs index c71f1bde..9ab05052 100644 --- a/src/journal/service/mod.rs +++ b/src/journal/service/mod.rs @@ -9,10 +9,7 @@ use crate::{ handler::MessageHandler, journal::{ command::JournalCommandRequest, - embedding::{ - Embedder, EmbedderError, Embedding, EmbeddingIndex, EmbeddingRepositoryError, - PendingEmbeddingCounter, - }, + embedding::{Embedder, EmbedderError, Embedding, EmbeddingIndex, EmbeddingRepositoryError}, extraction::service::JournalEntryExtractionRunner, responses::message_saved_response, review::{ @@ -20,7 +17,6 @@ use crate::{ service::DailyReviewRunner, }, search::{SearchService, SemanticSearchService}, - status::EmbeddingStatusConfig, store::JournalEntryStore, week_review::service::WeeklyReviewRunner, }, @@ -33,7 +29,6 @@ mod commands; #[derive(Clone)] pub struct JournalService { - repository: JournalRepository, store: JournalEntryStore, search: Option>, daily_review_search: Option>, @@ -41,17 +36,12 @@ pub struct JournalService { entry_extraction: Option>, daily_review: Option>, weekly_review: Option>, - embedding_status_config: Option, - pending_embedding_counter: Option>, - daily_review_prompt_version: Option, - daily_review_delivery_configured: bool, } impl JournalService { pub fn new(repository: JournalRepository) -> Self { let store = JournalEntryStore::new(repository.clone_pool()); Self { - repository, store, search: None, daily_review_search: None, @@ -59,10 +49,6 @@ impl JournalService { entry_extraction: None, daily_review: None, weekly_review: None, - embedding_status_config: None, - pending_embedding_counter: None, - daily_review_prompt_version: None, - daily_review_delivery_configured: false, } } @@ -87,19 +73,6 @@ impl JournalService { self } - pub fn with_embedding_status_config(mut self, config: EmbeddingStatusConfig) -> Self { - self.embedding_status_config = Some(config); - self - } - - pub fn with_pending_embedding_counter(mut self, counter: C) -> Self - where - C: PendingEmbeddingCounter + 'static, - { - self.pending_embedding_counter = Some(Arc::new(counter)); - self - } - pub fn with_capture_embedding(mut self, index: I, embedder: E) -> Self where I: EmbeddingIndex + Send + Sync + 'static, @@ -135,16 +108,6 @@ impl JournalService { self } - pub fn with_daily_review_prompt_version(mut self, prompt_version: impl Into) -> Self { - self.daily_review_prompt_version = Some(prompt_version.into()); - self - } - - pub fn with_daily_review_delivery_configured(mut self) -> Self { - self.daily_review_delivery_configured = true; - self - } - pub async fn process(&self, message: &IncomingMessage) -> Result { if let Some(journal_entry_id) = self.store.store(message).await? { self.spawn_background_tasks(journal_entry_id, message.text.clone()); diff --git a/src/journal/service/tests.rs b/src/journal/service/tests.rs index 4355eb39..55b58895 100644 --- a/src/journal/service/tests.rs +++ b/src/journal/service/tests.rs @@ -7,7 +7,7 @@ use sqlx::SqlitePool; use super::*; use crate::{ journal::{ - command::{DEFAULT_RECENT_LIMIT, JournalCommand, JournalCommandRequest, MAX_RECENT_LIMIT}, + command::{JournalCommand, JournalCommandRequest}, embedding::{ EmbedderError, Embedding, SUPPORTED_EMBEDDING_DIMENSIONS, SqliteEmbeddingRepository, }, @@ -146,10 +146,6 @@ struct FakeDailyReviewRunner { } impl FakeDailyReviewRunner { - fn new() -> Self { - Self::with_fetch_result(Ok(None)) - } - fn with_fetch_result( fetch_result: Result, DailyReviewServiceError>, ) -> Self { @@ -260,21 +256,6 @@ impl JournalEntryExtractionRunner for FakeJournalEntryExtractionRunner { } } -#[derive(Clone)] -struct FailingPendingEmbeddingCounter; - -#[async_trait::async_trait] -impl PendingEmbeddingCounter for FailingPendingEmbeddingCounter { - async fn count_entries_missing_embedding( - &self, - _embedding_model: &str, - ) -> Result { - Err(EmbeddingRepositoryError::Database( - "database path /tmp/secret.sqlite unavailable".to_string(), - )) - } -} - fn incoming( source_message_id: &str, text: &str, @@ -484,214 +465,6 @@ async fn command_start_returns_welcome_message() { ); } -#[tokio::test] -async fn status_returns_stable_sections_when_optional_subsystems_are_unavailable() { - let service = setup().await; - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - assert_eq!( - outgoing.text, - "Froid status\n\nJournal:\n- Total entries: 0\n- Entries today: 0\n\nEmbeddings:\n- Semantic search: unavailable\n- Model: unavailable\n- Dimensions: unavailable\n- Pending embeddings: unavailable\n\nDaily review:\n- Generation: not configured\n- Delivery: not configured" - ); -} - -#[tokio::test] -async fn status_uses_single_user_journal_stats_and_command_received_at_date() { - let (service, pool) = setup_with_pool().await; - service - .process(&incoming( - "1", - "previous day", - Utc.with_ymd_and_hms(2026, 4, 28, 23, 59, 0).unwrap(), - )) - .await - .unwrap(); - service - .process(&incoming( - "2", - "requested day", - Utc.with_ymd_and_hms(2026, 4, 29, 0, 0, 0).unwrap(), - )) - .await - .unwrap(); - JournalRepository::new(pool.clone()) - .store(&IncomingMessage { - source: MessageSource::Telegram, - source_conversation_id: "42".to_string(), - source_message_id: "3".to_string(), - text: "other user".to_string(), - received_at: Utc.with_ymd_and_hms(2026, 4, 29, 9, 0, 0).unwrap(), - }) - .await - .unwrap(); - - let outgoing = service - .command(&JournalCommandRequest { - source: MessageSource::Telegram, - source_conversation_id: "42".to_string(), - received_at: Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap(), - command: JournalCommand::Status, - }) - .await - .unwrap(); - - assert!(outgoing.text.contains("- Total entries: 3")); - assert!(outgoing.text.contains("- Entries today: 2")); -} - -#[tokio::test] -async fn status_command_does_not_store_command_text_as_journal_entry() { - let (service, pool) = setup_with_pool().await; - - service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - let entry_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM journal_entries") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(entry_count, 0); -} - -#[tokio::test] -async fn status_reports_configured_embedding_status_and_single_user_pending_count() { - let (service, index, repo) = setup_with_search(FakeEmbedder::fails()).await; - let service = service - .with_embedding_status_config(EmbeddingStatusConfig { - model: TEST_MODEL.to_string(), - }) - .with_pending_embedding_counter(index.clone()); - repo.store(&incoming("1", "embedded entry", at(10, 0))) - .await - .unwrap(); - repo.store(&incoming("2", "pending entry", at(11, 0))) - .await - .unwrap(); - let embedded_entry_id: String = - sqlx::query_scalar("SELECT id FROM journal_entries WHERE source_message_id = '1'") - .fetch_one(repo.pool()) - .await - .unwrap(); - index - .store_embedding( - &embedded_entry_id, - TEST_MODEL, - SUPPORTED_EMBEDDING_DIMENSIONS, - &Embedding::new( - vec![1.0; SUPPORTED_EMBEDDING_DIMENSIONS], - SUPPORTED_EMBEDDING_DIMENSIONS, - ) - .unwrap(), - ) - .await - .unwrap(); - repo.store(&IncomingMessage { - source: MessageSource::Telegram, - source_conversation_id: "42".to_string(), - source_message_id: "3".to_string(), - text: "other user pending entry".to_string(), - received_at: at(12, 0), - }) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - assert!(outgoing.text.contains("- Semantic search: enabled")); - assert!(outgoing.text.contains("- Model: test-model")); - assert!(outgoing.text.contains("- Dimensions: 1536")); - assert!(outgoing.text.contains("- Pending embeddings: 2")); -} - -#[tokio::test] -async fn status_reports_pending_embeddings_unavailable_when_counter_fails() { - let (service, _, _) = setup_with_search(FakeEmbedder::fails()).await; - let service = service - .with_embedding_status_config(EmbeddingStatusConfig { - model: TEST_MODEL.to_string(), - }) - .with_pending_embedding_counter(FailingPendingEmbeddingCounter); - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - assert!(outgoing.text.contains("- Semantic search: enabled")); - assert!(outgoing.text.contains("- Pending embeddings: unavailable")); - assert!(!outgoing.text.contains("/tmp/secret.sqlite")); - assert!(!outgoing.text.contains("database path")); -} - -#[tokio::test] -async fn status_reports_daily_review_prompt_when_configured() { - let runner = FakeDailyReviewRunner::new(); - let service = setup_with_daily_review_runner(runner) - .await - .with_daily_review_prompt_version("daily-review-v1"); - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - assert!(outgoing.text.contains("- Generation: configured")); - assert!(outgoing.text.contains("- Prompt: daily-review-v1")); - assert!(outgoing.text.contains("- Delivery: not configured")); -} - -#[tokio::test] -async fn status_reports_delivery_configured_when_delivery_is_wired() { - let runner = FakeDailyReviewRunner::new(); - let service = setup_with_daily_review_runner(runner) - .await - .with_daily_review_delivery_configured(); - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - assert!(outgoing.text.contains("- Delivery: configured")); -} - -#[tokio::test] -async fn status_does_not_expose_secrets_or_raw_internal_errors() { - let (service, _, _) = setup_with_search(FakeEmbedder::fails()).await; - let service = service - .with_embedding_status_config(EmbeddingStatusConfig { - model: TEST_MODEL.to_string(), - }) - .with_pending_embedding_counter(FailingPendingEmbeddingCounter); - - let outgoing = service - .command(&command(JournalCommand::Status)) - .await - .unwrap(); - - for forbidden in [ - "OPENAI_API_KEY", - "TELEGRAM_BOT_TOKEN", - "bot token", - "sqlite:", - "/tmp/secret.sqlite", - "provider down", - "database path", - "stack trace", - ] { - assert!(!outgoing.text.contains(forbidden), "{forbidden}"); - } -} - #[tokio::test] async fn day_review_last_returns_unavailable_when_runner_is_not_configured() { let (service, pool) = setup_with_pool().await; @@ -840,75 +613,6 @@ async fn undo_deletes_daily_review_for_deleted_entry_date() { assert!(persisted_review.is_none()); } -#[tokio::test] -async fn command_recent_usage_returns_usage_message() { - let service = setup().await; - - let outgoing = service - .command(&command(JournalCommand::RecentUsage)) - .await - .unwrap(); - - assert_eq!( - outgoing.text, - "Usage: /recent [number]\n\nExamples:\n/recent\n/recent 5" - ); -} - -#[tokio::test] -async fn last_returns_empty_response_when_no_entry_in_conversation() { - let service = setup().await; - - let outgoing = service - .command(&command(JournalCommand::Last)) - .await - .unwrap(); - - assert_eq!(outgoing.text, "No journal entry found."); -} - -#[tokio::test] -async fn last_formats_latest_entry_for_current_conversation() { - let service = setup().await; - service - .process(&incoming_for_conversation( - "42", - "1", - "current old", - at(10, 0), - )) - .await - .unwrap(); - service - .process(&incoming_for_conversation( - "99", - "2", - "other newer", - at(12, 0), - )) - .await - .unwrap(); - service - .process(&incoming_for_conversation( - "42", - "3", - "current new", - at(11, 0), - )) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Last)) - .await - .unwrap(); - - assert_eq!( - outgoing.text, - "Last entry:\n\n\"current new\"\n\nReceived at: 2026-04-28 11:00\n\nUse /undo to delete it." - ); -} - #[tokio::test] async fn undo_returns_empty_response_when_no_entry_in_conversation() { let service = setup().await; @@ -923,7 +627,7 @@ async fn undo_returns_empty_response_when_no_entry_in_conversation() { #[tokio::test] async fn undo_deletes_latest_entry_for_current_conversation() { - let service = setup().await; + let (service, pool) = setup_with_pool().await; service .process(&incoming_for_conversation( "42", @@ -956,186 +660,19 @@ async fn undo_deletes_latest_entry_for_current_conversation() { .command(&command(JournalCommand::Undo)) .await .unwrap(); - let last_current = service - .command(&command(JournalCommand::Last)) - .await - .unwrap(); - let last_other = service - .command(&JournalCommandRequest { - source: MessageSource::Telegram, - source_conversation_id: "99".to_string(), - received_at: at(12, 0), - command: JournalCommand::Last, - }) - .await - .unwrap(); assert_eq!(undo.text, "Deleted last entry."); - assert_eq!( - last_current.text, - "Last entry:\n\n\"current old\"\n\nReceived at: 2026-04-28 10:00\n\nUse /undo to delete it." - ); - assert_eq!( - last_other.text, - "Last entry:\n\n\"other newer\"\n\nReceived at: 2026-04-28 12:00\n\nUse /undo to delete it." - ); -} - -#[tokio::test] -async fn recent_returns_empty_response_when_no_entries() { - let service = setup().await; - - let result = service - .command(&command(JournalCommand::Recent { - requested_limit: DEFAULT_RECENT_LIMIT, - })) - .await - .unwrap(); - - assert_eq!(result.text, "No journal entries found."); -} -#[tokio::test] -async fn recent_formats_entries_newest_first() { - let service = setup().await; - - service - .process(&incoming("1", "first", at(10, 0))) - .await - .unwrap(); - service - .process(&incoming("2", "second", at(11, 0))) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Recent { - requested_limit: 10, - })) - .await - .unwrap(); - - assert_eq!( - outgoing.text, - "2026-04-28 11:00 - second\n2026-04-28 10:00 - first" - ); -} - -#[tokio::test] -async fn recent_respects_limit() { - let service = setup().await; - - service - .process(&incoming("1", "first", at(10, 0))) - .await - .unwrap(); - service - .process(&incoming("2", "second", at(11, 0))) - .await - .unwrap(); - service - .process(&incoming("3", "third", at(12, 0))) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Recent { requested_limit: 2 })) - .await - .unwrap(); - - assert!(outgoing.text.contains("third")); - assert!(outgoing.text.contains("second")); - assert!(!outgoing.text.contains("first")); -} - -#[tokio::test] -async fn recent_caps_requested_limit() { - let service = setup().await; - - for index in 1..=51 { - service - .process(&incoming( - &index.to_string(), - &format!("entry {index}"), - Utc.with_ymd_and_hms(2026, 4, 28, 0, index, 0).unwrap(), - )) + // Only the latest entry of conversation 42 ("current new") is removed; the + // older entry of 42 and the entry of conversation 99 are left untouched. + let remaining: Vec = + sqlx::query_scalar("SELECT raw_text FROM journal_entries ORDER BY received_at") + .fetch_all(&pool) .await .unwrap(); - } - - let outgoing = service - .command(&command(JournalCommand::Recent { - requested_limit: 100, - })) - .await - .unwrap(); - - assert_eq!(outgoing.text.lines().count(), MAX_RECENT_LIMIT as usize); - assert!(outgoing.text.contains("entry 51")); - assert!(!outgoing.text.contains("2026-04-28 00:01 - entry 1")); -} - -#[tokio::test] -async fn today_formats_entries_oldest_first() { - let service = setup().await; - - service - .process(&incoming("1", "first", at(10, 0))) - .await - .unwrap(); - service - .process(&incoming("2", "second", at(11, 0))) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Today)) - .await - .unwrap(); - - assert_eq!( - outgoing.text, - "2026-04-28 10:00 - first\n2026-04-28 11:00 - second" - ); -} - -#[tokio::test] -async fn today_returns_empty_response_when_no_entries() { - let service = setup().await; - - let outgoing = service - .command(&command(JournalCommand::Today)) - .await - .unwrap(); - - assert_eq!(outgoing.text, "No journal entries found for today."); -} - -#[tokio::test] -async fn stats_formats_basic_statistics() { - let service = setup().await; - - service - .process(&incoming("1", "first", at(10, 0))) - .await - .unwrap(); - service - .process(&incoming( - "2", - "tomorrow", - Utc.with_ymd_and_hms(2026, 4, 29, 9, 0, 0).unwrap(), - )) - .await - .unwrap(); - - let outgoing = service - .command(&command(JournalCommand::Stats)) - .await - .unwrap(); - assert_eq!( - outgoing.text, - "Journal stats:\nTotal entries: 2\nEntries today: 1\nLatest entry: 2026-04-29 09:00" + remaining, + vec!["current old".to_string(), "other newer".to_string()] ); } diff --git a/src/journal/status.rs b/src/journal/status.rs deleted file mode 100644 index 47a67c6b..00000000 --- a/src/journal/status.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::entry::JournalStats; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StatusReport { - pub journal: JournalStats, - pub embeddings: EmbeddingStatus, - pub daily_review: DailyReviewStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EmbeddingStatus { - pub semantic_search: SemanticSearchStatus, - pub config: Option, - pub pending_embeddings: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EmbeddingStatusConfig { - pub model: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SemanticSearchStatus { - Enabled, - Unavailable, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DailyReviewStatus { - pub generation: DailyReviewGenerationStatus, - pub prompt_version: Option, - pub delivery: DailyReviewDeliveryStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DailyReviewGenerationStatus { - Configured, - NotConfigured, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DailyReviewDeliveryStatus { - Configured, - NotConfigured, -} diff --git a/tests/multiuser_tests.rs b/tests/multiuser_tests.rs index 011e9508..4645355d 100644 --- a/tests/multiuser_tests.rs +++ b/tests/multiuser_tests.rs @@ -2,12 +2,25 @@ use clap::Parser; use froid::{ cli::Cli, handler::MessageHandler, - journal::command::{JournalCommand, JournalCommandRequest}, journal::{registry::JournalServiceRegistry, registry::JournalServiceRegistryConfig}, messages::{IncomingMessage, MessageSource}, }; use tokio_util::sync::CancellationToken; +/// Read every journal entry text stored in a tenant's database file. +async fn entry_texts(db_path: &std::path::Path) -> Vec { + use sqlx::Row; + + let pool = sqlx::SqlitePool::connect(&format!("sqlite:{}", db_path.display())) + .await + .unwrap(); + let rows = sqlx::query("SELECT raw_text FROM journal_entries ORDER BY received_at") + .fetch_all(&pool) + .await + .unwrap(); + rows.into_iter().map(|row| row.get("raw_text")).collect() +} + #[tokio::test] async fn test_multiuser_database_isolation_and_routing() { // 1. Create a unique temporary directory for this test @@ -86,49 +99,31 @@ async fn test_multiuser_database_isolation_and_routing() { db_b_path ); - // 6. Verify Isolation via /recent command - // Query recent entries for User A - let cmd_a = JournalCommandRequest { - source: MessageSource::Telegram, - source_conversation_id: "user_a".to_string(), - received_at: chrono::Utc::now(), - command: JournalCommand::Recent { - requested_limit: 10, - }, - }; - - let res_recent_a = registry.command(&cmd_a).await.unwrap(); + // 6. Verify isolation by inspecting each tenant's physical database. + let texts_a = entry_texts(&db_a_path).await; assert!( - res_recent_a.text.contains("Today was a productive day"), - "User A's recent list should contain their own message. Got: {}", - res_recent_a.text + texts_a + .iter() + .any(|t| t.contains("Today was a productive day")), + "User A's database should contain their own message. Got: {:?}", + texts_a ); assert!( - !res_recent_a.text.contains("gardening"), - "User A's recent list must NOT contain User B's message. Got: {}", - res_recent_a.text + !texts_a.iter().any(|t| t.contains("gardening")), + "User A's database must NOT contain User B's message. Got: {:?}", + texts_a ); - // Query recent entries for User B - let cmd_b = JournalCommandRequest { - source: MessageSource::Telegram, - source_conversation_id: "user_b".to_string(), - received_at: chrono::Utc::now(), - command: JournalCommand::Recent { - requested_limit: 10, - }, - }; - - let res_recent_b = registry.command(&cmd_b).await.unwrap(); + let texts_b = entry_texts(&db_b_path).await; assert!( - res_recent_b.text.contains("gardening"), - "User B's recent list should contain their own message. Got: {}", - res_recent_b.text + texts_b.iter().any(|t| t.contains("gardening")), + "User B's database should contain their own message. Got: {:?}", + texts_b ); assert!( - !res_recent_b.text.contains("productive day"), - "User B's recent list must NOT contain User A's message. Got: {}", - res_recent_b.text + !texts_b.iter().any(|t| t.contains("productive day")), + "User B's database must NOT contain User A's message. Got: {:?}", + texts_b ); // 7. Verify Startup Database Discovery @@ -150,13 +145,32 @@ async fn test_multiuser_database_isolation_and_routing() { discovery_res.err() ); - // Verify both tenant services were loaded and cached - // We can query recent again using the restarted registry without sending a new message first - let res_restart_a = registry_restart.command(&cmd_a).await.unwrap(); + // The restarted registry must route to User A's existing database: a new + // message lands alongside the entry stored before the restart rather than + // in a fresh database. + let msg_a_again = IncomingMessage { + source: MessageSource::Telegram, + source_conversation_id: "user_a".to_string(), + source_message_id: "msg_3".to_string(), + text: "A second entry written after restart.".to_string(), + received_at: chrono::Utc::now(), + }; + registry_restart.process(&msg_a_again).await.unwrap(); + + let texts_a_after_restart = entry_texts(&db_a_path).await; + assert!( + texts_a_after_restart + .iter() + .any(|t| t.contains("Today was a productive day")), + "Restarted registry should keep User A's original entry. Got: {:?}", + texts_a_after_restart + ); assert!( - res_restart_a.text.contains("Today was a productive day"), - "Restarted registry should discover User A's DB and load existing entries. Got: {}", - res_restart_a.text + texts_a_after_restart + .iter() + .any(|t| t.contains("second entry written after restart")), + "Restarted registry should append to User A's existing database. Got: {:?}", + texts_a_after_restart ); // Clean up temporary database files