From 08d527366e83990a50797dba26a3a31ca3192f8b Mon Sep 17 00:00:00 2001 From: Vanalite Date: Thu, 2 Oct 2025 20:53:46 +0700 Subject: [PATCH] feat: organize code for proper import Move platform checker for db access to helper Add test for to threads controller --- src-tauri/src/core/threads/commands.rs | 37 +- src-tauri/src/core/threads/db.rs | 9 - src-tauri/src/core/threads/helpers.rs | 5 + src-tauri/src/core/threads/mod.rs | 1 + src-tauri/src/core/threads/tests.rs | 331 ++++++++++++++++++ .../__tests__/serviceHub.integration.test.ts | 8 +- 6 files changed, 368 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/core/threads/commands.rs b/src-tauri/src/core/threads/commands.rs index 034d7d536..f53239206 100644 --- a/src-tauri/src/core/threads/commands.rs +++ b/src-tauri/src/core/threads/commands.rs @@ -3,9 +3,11 @@ use std::io::Write; use tauri::Runtime; use uuid::Uuid; +#[cfg(any(target_os = "android", target_os = "ios"))] use super::db; use super::helpers::{ - get_lock_for_thread, read_messages_from_file, update_thread_metadata, write_messages_to_file, + get_lock_for_thread, read_messages_from_file, should_use_sqlite, update_thread_metadata, + write_messages_to_file, }; use super::{ constants::THREADS_FILE, @@ -21,8 +23,9 @@ use super::{ pub async fn list_threads( app_handle: tauri::AppHandle, ) -> Result, String> { - if db::should_use_sqlite() { + if should_use_sqlite() { // Use SQLite on mobile platforms + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_list_threads(app_handle).await; } @@ -63,7 +66,8 @@ pub async fn create_thread( app_handle: tauri::AppHandle, mut thread: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_thread(app_handle, thread).await; } @@ -88,7 +92,8 @@ pub async fn modify_thread( app_handle: tauri::AppHandle, thread: serde_json::Value, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_thread(app_handle, thread).await; } @@ -113,7 +118,8 @@ pub async fn delete_thread( app_handle: tauri::AppHandle, thread_id: String, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_delete_thread(app_handle, &thread_id).await; } @@ -132,7 +138,8 @@ pub async fn list_messages( app_handle: tauri::AppHandle, thread_id: String, ) -> Result, String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_list_messages(app_handle, &thread_id).await; } @@ -147,7 +154,8 @@ pub async fn create_message( app_handle: tauri::AppHandle, mut message: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_message(app_handle, message).await; } @@ -198,7 +206,8 @@ pub async fn modify_message( app_handle: tauri::AppHandle, message: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_message(app_handle, message).await; } @@ -241,7 +250,8 @@ pub async fn delete_message( thread_id: String, message_id: String, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_delete_message(app_handle, &thread_id, &message_id).await; } @@ -269,7 +279,8 @@ pub async fn get_thread_assistant( app_handle: tauri::AppHandle, thread_id: String, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_get_thread_assistant(app_handle, &thread_id).await; } @@ -299,7 +310,8 @@ pub async fn create_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_thread_assistant(app_handle, &thread_id, assistant).await; } @@ -329,7 +341,8 @@ pub async fn modify_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_thread_assistant(app_handle, &thread_id, assistant).await; } diff --git a/src-tauri/src/core/threads/db.rs b/src-tauri/src/core/threads/db.rs index 0d8e88438..b888b94bb 100644 --- a/src-tauri/src/core/threads/db.rs +++ b/src-tauri/src/core/threads/db.rs @@ -23,17 +23,8 @@ const DB_NAME: &str = "jan.db"; /// Global database pool for mobile platforms static DB_POOL: OnceLock>> = OnceLock::new(); -/// Check if the platform should use SQLite (mobile platforms) -pub fn should_use_sqlite() -> bool { - cfg!(any(target_os = "android", target_os = "ios")) -} - /// Initialize database with connection pool and run migrations pub async fn init_database(app: &AppHandle) -> Result<(), String> { - if !should_use_sqlite() { - return Ok(()); // Skip DB initialization on desktop - } - // Get app data directory let app_data_dir = app .path() diff --git a/src-tauri/src/core/threads/helpers.rs b/src-tauri/src/core/threads/helpers.rs index 76d2c2e59..06b3561d9 100644 --- a/src-tauri/src/core/threads/helpers.rs +++ b/src-tauri/src/core/threads/helpers.rs @@ -13,6 +13,11 @@ use super::utils::{get_messages_path, get_thread_metadata_path}; // Global per-thread locks for message file writes pub static MESSAGE_LOCKS: OnceLock>>>> = OnceLock::new(); +/// Check if the platform should use SQLite (mobile platforms) +pub fn should_use_sqlite() -> bool { + cfg!(any(target_os = "android", target_os = "ios")) +} + /// Get a lock for a specific thread to ensure thread-safe message file operations pub async fn get_lock_for_thread(thread_id: &str) -> Arc> { let locks = MESSAGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); diff --git a/src-tauri/src/core/threads/mod.rs b/src-tauri/src/core/threads/mod.rs index f868899dd..99c00253e 100644 --- a/src-tauri/src/core/threads/mod.rs +++ b/src-tauri/src/core/threads/mod.rs @@ -12,6 +12,7 @@ pub mod commands; mod constants; +#[cfg(any(target_os = "android", target_os = "ios"))] pub mod db; pub mod helpers; pub mod utils; diff --git a/src-tauri/src/core/threads/tests.rs b/src-tauri/src/core/threads/tests.rs index 8d3524d06..a6170dfe7 100644 --- a/src-tauri/src/core/threads/tests.rs +++ b/src-tauri/src/core/threads/tests.rs @@ -1,5 +1,6 @@ use super::commands::*; +use super::helpers::should_use_sqlite; use serde_json::json; use std::fs; use std::path::PathBuf; @@ -23,6 +24,32 @@ fn mock_app_with_temp_data_dir() -> (tauri::App, PathBuf) { (app, data_dir) } +// Helper to create a basic thread +fn create_test_thread(title: &str) -> serde_json::Value { + json!({ + "object": "thread", + "title": title, + "assistants": [], + "created": 123, + "updated": 123, + "metadata": null + }) +} + +// Helper to create a basic message +fn create_test_message(thread_id: &str, content_text: &str) -> serde_json::Value { + json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [{"type": "text", "text": content_text}], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }) +} + #[tokio::test] async fn test_create_and_list_threads() { let (app, data_dir) = mock_app_with_temp_data_dir(); @@ -137,3 +164,307 @@ async fn test_create_and_get_thread_assistant() { // Clean up let _ = fs::remove_dir_all(data_dir); } + +#[test] +fn test_should_use_sqlite_platform_detection() { + // Test that should_use_sqlite returns correct value based on platform + // On desktop platforms (macOS, Linux, Windows), it should return false + // On mobile platforms (Android, iOS), it should return true + + #[cfg(any(target_os = "android", target_os = "ios"))] + { + assert!(should_use_sqlite(), "should_use_sqlite should return true on mobile platforms"); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + assert!(!should_use_sqlite(), "should_use_sqlite should return false on desktop platforms"); + } +} + +#[tokio::test] +async fn test_desktop_storage_backend() { + // This test verifies that on desktop platforms, the file-based storage is used + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let (app, _data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Desktop Test Thread", + "assistants": [], + "created": 1234567890, + "updated": 1234567890, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Verify we can retrieve the thread (which proves file storage works) + let threads = list_threads(app.handle().clone()).await.unwrap(); + let found = threads.iter().any(|t| t["id"] == thread_id); + assert!(found, "Thread should be retrievable from file-based storage"); + + // Create a message + let message = json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }); + + let _created_msg = create_message(app.handle().clone(), message).await.unwrap(); + + // Verify we can retrieve the message (which proves file storage works) + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 1, "Message should be retrievable from file-based storage"); + + // Clean up - get the actual data directory used by the app + use super::utils::get_data_dir; + let actual_data_dir = get_data_dir(app.handle().clone()); + let _ = fs::remove_dir_all(actual_data_dir); + } +} + +#[tokio::test] +async fn test_modify_and_delete_thread() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Original Title", + "assistants": [], + "created": 1234567890, + "updated": 1234567890, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Modify the thread + let mut modified_thread = created.clone(); + modified_thread["title"] = json!("Modified Title"); + + modify_thread(app.handle().clone(), modified_thread.clone()) + .await + .unwrap(); + + // Verify modification by listing threads + let threads = list_threads(app.handle().clone()).await.unwrap(); + let found_thread = threads.iter().find(|t| t["id"] == thread_id); + assert!(found_thread.is_some(), "Modified thread should exist"); + assert_eq!(found_thread.unwrap()["title"], "Modified Title"); + + // Delete the thread + delete_thread(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + + // Verify deletion + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let thread_dir = data_dir.join(&thread_id); + assert!(!thread_dir.exists(), "Thread directory should be deleted"); + } + + // Clean up + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_modify_and_delete_message() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Message Test Thread", + "assistants": [], + "created": 123, + "updated": 123, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Create a message + let message = json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [{"type": "text", "text": "Original content"}], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }); + + let created_msg = create_message(app.handle().clone(), message).await.unwrap(); + let message_id = created_msg["id"].as_str().unwrap().to_string(); + + // Modify the message + let mut modified_msg = created_msg.clone(); + modified_msg["content"] = json!([{"type": "text", "text": "Modified content"}]); + + modify_message(app.handle().clone(), modified_msg.clone()) + .await + .unwrap(); + + // Verify modification + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["content"][0]["text"], "Modified content"); + + // Delete the message + delete_message(app.handle().clone(), thread_id.clone(), message_id.clone()) + .await + .unwrap(); + + // Verify deletion + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 0, "Message should be deleted"); + + // Clean up + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_modify_thread_assistant() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Assistant Mod Thread")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let assistant = json!({ + "id": "assistant-1", + "assistant_name": "Original Assistant", + "model": {"id": "model-1", "name": "Test Model"} + }); + + create_thread_assistant(app_handle.clone(), thread_id.to_string(), assistant.clone()) + .await + .unwrap(); + + let mut modified_assistant = assistant; + modified_assistant["assistant_name"] = json!("Modified Assistant"); + + modify_thread_assistant(app_handle.clone(), thread_id.to_string(), modified_assistant) + .await + .unwrap(); + + let retrieved = get_thread_assistant(app_handle, thread_id.to_string()) + .await + .unwrap(); + assert_eq!(retrieved["assistant_name"], "Modified Assistant"); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_thread_not_found_errors() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + let fake_thread_id = "non-existent-thread-id".to_string(); + let assistant = json!({"id": "assistant-1", "assistant_name": "Test Assistant"}); + + assert!(get_thread_assistant(app_handle.clone(), fake_thread_id.clone()).await.is_err()); + assert!(create_thread_assistant(app_handle.clone(), fake_thread_id.clone(), assistant.clone()).await.is_err()); + assert!(modify_thread_assistant(app_handle, fake_thread_id, assistant).await.is_err()); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_message_without_id_gets_generated() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Message ID Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let message = json!({"object": "message", "thread_id": thread_id, "role": "user", "content": [], "status": "sent"}); + let created_msg = create_message(app_handle, message).await.unwrap(); + + assert!(created_msg["id"].as_str().is_some_and(|id| !id.is_empty())); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_concurrent_message_operations() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Concurrent Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + let handles: Vec<_> = (0..5) + .map(|i| { + let app_h = app_handle.clone(); + let tid = thread_id.clone(); + tokio::spawn(async move { + create_message(app_h, create_test_message(&tid, &format!("Message {}", i))).await + }) + }) + .collect(); + + let results = futures::future::join_all(handles).await; + assert!(results.iter().all(|r| r.is_ok() && r.as_ref().unwrap().is_ok())); + + let messages = list_messages(app_handle, thread_id).await.unwrap(); + assert_eq!(messages.len(), 5); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_empty_thread_list() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let threads = list_threads(app.handle().clone()).await.unwrap(); + assert_eq!(threads.len(), 0); + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_empty_message_list() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Empty Messages Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let messages = list_messages(app_handle, thread_id.to_string()).await.unwrap(); + assert_eq!(messages.len(), 0); + + let _ = fs::remove_dir_all(data_dir); +} diff --git a/web-app/src/services/__tests__/serviceHub.integration.test.ts b/web-app/src/services/__tests__/serviceHub.integration.test.ts index 8a8a10344..b39a24831 100644 --- a/web-app/src/services/__tests__/serviceHub.integration.test.ts +++ b/web-app/src/services/__tests__/serviceHub.integration.test.ts @@ -4,7 +4,11 @@ import { isPlatformTauri } from '@/lib/platform/utils' // Mock platform detection vi.mock('@/lib/platform/utils', () => ({ - isPlatformTauri: vi.fn().mockReturnValue(false) + isPlatformTauri: vi.fn().mockReturnValue(false), + isPlatformIOS: vi.fn().mockReturnValue(false), + isPlatformAndroid: vi.fn().mockReturnValue(false), + isIOS: vi.fn().mockReturnValue(false), + isAndroid: vi.fn().mockReturnValue(false) })) // Mock @jan/extensions-web to return empty extensions for testing @@ -213,4 +217,4 @@ describe('ServiceHub Integration Tests', () => { }) }) -}) +})