1
0
forked from boos/BoosAPI

feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
use std::path::{Path, PathBuf};
use boosapi_core::repo::codex_session_visibility::repair_codex_session_visibility_at;
use sea_orm::{ConnectionTrait, Database, DbBackend, Statement};
use serde_json::{json, Value};
fn codex_home_root() -> PathBuf {
std::env::temp_dir().join(format!(
"boosapi-codex-session-visibility-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
fn write_text(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(path, content).expect("write text fixture");
}
fn write_json(path: &Path, value: &Value) {
let content = serde_json::to_string_pretty(value).expect("serialize json fixture");
write_text(path, &content);
}
fn rollout_path(codex_home: &Path, dir: &str, day: &str, thread: &str) -> PathBuf {
codex_home
.join(dir)
.join("2026")
.join("06")
.join(day)
.join(format!("rollout-{thread}.jsonl"))
}
fn write_rollout(path: &Path, thread_id: &str, provider: &str, cwd: &str, include_user: bool) {
let timestamp = "2026-06-09T00:00:00.000Z";
let mut lines = vec![json!({
"timestamp": timestamp, "type": "session_meta",
"payload": { "id": thread_id, "timestamp": timestamp, "cwd": cwd,
"source": "cli", "cli_version": "0.121.0", "model_provider": provider }
})
.to_string()];
if include_user {
lines.push(
json!({
"timestamp": timestamp, "type": "event_msg",
"payload": { "type": "user_message", "message": "hello" }
})
.to_string(),
);
}
lines.push(
json!({
"timestamp": timestamp, "type": "event_msg",
"payload": { "type": "assistant_message", "encrypted_content": "ciphertext" }
})
.to_string(),
);
write_text(path, &format!("{}\n", lines.join("\n")));
}
fn first_line_json(path: &Path) -> Value {
let content = std::fs::read_to_string(path).expect("read rollout");
let first_line = content.lines().next().expect("rollout first line");
serde_json::from_str(first_line).expect("parse rollout first line")
}
async fn create_state_db(codex_home: &Path) {
let db_path = codex_home.join("state_5.sqlite");
let db = Database::connect(format!("sqlite://{}?mode=rwc", db_path.display()))
.await
.expect("connect state db");
db.execute(Statement::from_string(
DbBackend::Sqlite,
r#"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
archived INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
first_user_message TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL DEFAULT 0
)"#
.to_string(),
))
.await
.expect("create threads");
db.execute(Statement::from_string(
DbBackend::Sqlite,
r#"INSERT INTO threads
(id, model_provider, cwd, archived, has_user_event, first_user_message, updated_at_ms)
VALUES
('thread-a', 'openai', '', 0, 0, 'hello', 123),
('thread-b', 'old-provider', '', 1, 0, 'archived', 456)"#
.to_string(),
))
.await
.expect("insert threads");
}
async fn read_thread_rows(codex_home: &Path) -> Vec<(String, String, String, i64, i64)> {
let db_path = codex_home.join("state_5.sqlite");
let db = Database::connect(format!("sqlite://{}?mode=ro", db_path.display()))
.await
.expect("connect state db read");
let rows = db
.query_all(Statement::from_string(
DbBackend::Sqlite,
"SELECT id, model_provider, cwd, has_user_event, updated_at_ms FROM threads ORDER BY id"
.to_string(),
))
.await
.expect("query threads");
rows.into_iter()
.map(|row| {
(
row.try_get::<String>("", "id").expect("id"),
row.try_get::<String>("", "model_provider")
.expect("model_provider"),
row.try_get::<String>("", "cwd").expect("cwd"),
row.try_get::<i64>("", "has_user_event")
.expect("has_user_event"),
row.try_get::<i64>("", "updated_at_ms")
.expect("updated_at_ms"),
)
})
.collect()
}
#[tokio::test]
async fn codex_session_visibility_syncs_rollouts_sqlite_and_global_state() {
let codex_home = codex_home_root();
let active_rollout = rollout_path(&codex_home, "sessions", "09", "thread-a");
let archived_rollout = rollout_path(&codex_home, "archived_sessions", "08", "thread-b");
write_text(
&codex_home.join("config.toml"),
r#"model_provider = "any"
[model_providers.any]
base_url = "http://localhost:1234/v1"
"#,
);
write_json(
&codex_home.join("auth.json"),
&json!({ "OPENAI_API_KEY": "keep-me" }),
);
write_rollout(
&active_rollout,
"thread-a",
"openai",
"/tmp/project-alpha",
true,
);
write_rollout(
&archived_rollout,
"thread-b",
"old-provider",
"/tmp/project-alpha",
false,
);
write_json(
&codex_home.join(".codex-global-state.json"),
&json!({
"electron-saved-workspace-roots": ["/tmp/project-alpha/"],
"project-order": [],
"active-workspace-roots": "/tmp/project-alpha/"
}),
);
create_state_db(&codex_home).await;
let result = repair_codex_session_visibility_at(&codex_home)
.await
.expect("repair succeeds");
assert_eq!(result.target_provider, "any");
assert_eq!(result.changed_session_files, 2);
assert_eq!(result.skipped_locked_session_files, 0);
assert_eq!(result.sqlite_provider_rows_updated, 2);
assert_eq!(result.sqlite_user_event_rows_updated, 1);
assert_eq!(result.sqlite_cwd_rows_updated, 2);
assert_eq!(result.updated_workspace_roots, 1);
assert!(result
.backup_dir
.as_deref()
.expect("backup dir")
.contains("boosapi-session-visibility"));
assert!(result
.encrypted_content_warning
.as_deref()
.expect("encrypted content warning")
.contains("encrypted_content"));
assert_eq!(
first_line_json(&active_rollout)["payload"]["model_provider"],
"any"
);
assert_eq!(
first_line_json(&archived_rollout)["payload"]["model_provider"],
"any"
);
let active_content = std::fs::read_to_string(&active_rollout).expect("read active rollout");
assert!(active_content.contains("encrypted_content"));
let rows = read_thread_rows(&codex_home).await;
assert_eq!(
rows,
vec![
(
"thread-a".into(),
"any".into(),
"/tmp/project-alpha".into(),
1,
123
),
(
"thread-b".into(),
"any".into(),
"/tmp/project-alpha".into(),
0,
456
),
]
);
assert_eq!(
std::fs::read_to_string(codex_home.join("auth.json")).expect("read auth"),
"{\n \"OPENAI_API_KEY\": \"keep-me\"\n}"
);
let global_state = serde_json::from_str::<Value>(
&std::fs::read_to_string(codex_home.join(".codex-global-state.json"))
.expect("read global state"),
)
.expect("parse global state");
assert_eq!(
global_state["electron-saved-workspace-roots"],
json!(["/tmp/project-alpha"])
);
assert!(codex_home.join(".codex-global-state.json.bak").exists());
let _ = std::fs::remove_dir_all(codex_home);
}
#[tokio::test]
async fn codex_session_visibility_succeeds_when_sqlite_is_missing() {
let codex_home = codex_home_root();
let rollout = rollout_path(&codex_home, "sessions", "09", "thread-a");
write_text(
&codex_home.join("config.toml"),
"sandbox_mode = \"workspace-write\"\n",
);
write_rollout(
&rollout,
"thread-a",
"old-provider",
"/tmp/project-beta",
true,
);
let result = repair_codex_session_visibility_at(&codex_home)
.await
.expect("repair succeeds without sqlite");
assert_eq!(result.target_provider, "openai");
assert_eq!(result.changed_session_files, 1);
assert!(!result.sqlite_present);
assert_eq!(
first_line_json(&rollout)["payload"]["model_provider"],
"openai"
);
let _ = std::fs::remove_dir_all(codex_home);
}
#[tokio::test]
async fn codex_session_visibility_does_not_rewrite_rollout_when_sqlite_is_malformed() {
let codex_home = codex_home_root();
let rollout = rollout_path(&codex_home, "sessions", "09", "thread-a");
write_text(
&codex_home.join("config.toml"),
"model_provider = \"any\"\n",
);
write_rollout(
&rollout,
"thread-a",
"old-provider",
"/tmp/project-gamma",
true,
);
write_text(&codex_home.join("state_5.sqlite"), "not a sqlite database");
let error = repair_codex_session_visibility_at(&codex_home)
.await
.expect_err("malformed sqlite stops repair");
assert!(error.to_string().contains("state_5.sqlite"));
assert_eq!(
first_line_json(&rollout)["payload"]["model_provider"],
"old-provider"
);
let _ = std::fs::remove_dir_all(codex_home);
}
@@ -0,0 +1,166 @@
use std::path::{Path, PathBuf};
use boosapi_core::repo::codex_session_visibility::{
get_codex_session_visibility_status_at, repair_codex_session_visibility_with_backup_at,
};
use sea_orm::{ConnectionTrait, Database, DbBackend, Statement};
use serde_json::json;
fn codex_home_root() -> PathBuf {
std::env::temp_dir().join(format!(
"boosapi-codex-session-status-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
fn write_text(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, content).expect("write fixture");
}
fn rollout_path(codex_home: &Path, dir: &str, thread: &str) -> PathBuf {
codex_home
.join(dir)
.join("2026")
.join("06")
.join("09")
.join(format!("rollout-{thread}.jsonl"))
}
fn write_rollout(path: &Path, thread_id: &str, provider: &str, cwd: &str, user: bool) {
let timestamp = "2026-06-09T00:00:00.000Z";
let mut lines = vec![json!({
"timestamp": timestamp, "type": "session_meta",
"payload": { "id": thread_id, "timestamp": timestamp, "cwd": cwd,
"source": "cli", "cli_version": "0.121.0", "model_provider": provider }
})
.to_string()];
if user {
lines.push(
json!({
"timestamp": timestamp, "type": "event_msg",
"payload": { "type": "user_message", "message": "hello" }
})
.to_string(),
);
}
lines.push(
json!({
"timestamp": timestamp, "type": "event_msg",
"payload": { "type": "assistant_message", "encrypted_content": "cipher" }
})
.to_string(),
);
write_text(path, &format!("{}\n", lines.join("\n")));
}
async fn create_state_db(codex_home: &Path) {
let db_path = codex_home.join("state_5.sqlite");
let db = Database::connect(format!("sqlite://{}?mode=rwc", db_path.display()))
.await
.expect("connect sqlite");
db.execute(Statement::from_string(
DbBackend::Sqlite,
r#"CREATE TABLE threads (
id TEXT PRIMARY KEY,
model_provider TEXT,
cwd TEXT NOT NULL DEFAULT '',
has_user_event INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL DEFAULT 0
)"#
.to_string(),
))
.await
.expect("create threads");
db.execute(Statement::from_string(
DbBackend::Sqlite,
r#"INSERT INTO threads
(id, model_provider, cwd, has_user_event, updated_at_ms)
VALUES
('thread-a', 'any', '/tmp/project-alpha', 1, 123),
('thread-b', 'openai', '', 0, 456)"#
.to_string(),
))
.await
.expect("insert threads");
}
#[tokio::test]
async fn status_reports_mismatched_rollouts_sqlite_and_workspace_roots_without_writes() {
let codex_home = codex_home_root();
write_text(
&codex_home.join("config.toml"),
"model_provider = \"any\"\n",
);
write_rollout(
&rollout_path(&codex_home, "sessions", "thread-a"),
"thread-a",
"any",
"/tmp/project-alpha",
true,
);
write_rollout(
&rollout_path(&codex_home, "archived_sessions", "thread-b"),
"thread-b",
"openai",
"/tmp/project-beta",
true,
);
write_text(
&codex_home.join(".codex-global-state.json"),
r#"{"electron-saved-workspace-roots":["/tmp/project-alpha/"],"project-order":[]}"#,
);
create_state_db(&codex_home).await;
let status = get_codex_session_visibility_status_at(&codex_home)
.await
.expect("status succeeds");
assert_eq!(status.target_provider, "any");
assert_eq!(status.total_session_files, 2);
assert_eq!(status.mismatched_session_files, 1);
assert!(status.sqlite_present);
assert_eq!(status.sqlite_rows, 2);
assert_eq!(status.sqlite_mismatched_rows, 1);
assert_eq!(status.sqlite_user_event_rows_needing_repair, 1);
assert_eq!(status.sqlite_cwd_rows_needing_repair, 1);
assert_eq!(status.workspace_roots_needing_update, 1);
assert!(status
.status_rows
.iter()
.any(|row| row.scope == "archived_sessions"
&& row.provider.as_deref() == Some("openai")
&& row.mismatched_count == 1));
assert!(!codex_home.join(".codex-global-state.json.bak").exists());
let _ = std::fs::remove_dir_all(codex_home);
}
#[tokio::test]
async fn repair_can_skip_persistent_backup_when_requested() {
let codex_home = codex_home_root();
write_text(
&codex_home.join("config.toml"),
"model_provider = \"any\"\n",
);
write_rollout(
&rollout_path(&codex_home, "sessions", "thread-a"),
"thread-a",
"openai",
"/tmp/project-alpha",
true,
);
let result = repair_codex_session_visibility_with_backup_at(&codex_home, false)
.await
.expect("repair succeeds");
assert_eq!(result.changed_session_files, 1);
assert!(result.backup_dir.is_none());
assert!(!codex_home.join("backups_state").exists());
let _ = std::fs::remove_dir_all(codex_home);
}
@@ -0,0 +1,88 @@
use boosapi_core::db::create_test_pool;
use boosapi_core::repo::{gateway, provider};
use boosapi_core::types::{CreateProviderInput, ProviderType};
async fn seed_gateway_usage() -> (
boosapi_core::db::DbHandle,
boosapi_core::types::GatewayKey,
boosapi_core::types::ProviderConfig,
) {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let key = gateway::create_gateway_key(db, "Gateway Usage Test Key", None)
.await
.unwrap()
.gateway_key;
let provider = provider::create_provider(
db,
CreateProviderInput {
name: "DeepSeek".into(),
provider_type: ProviderType::OpenAI,
api_host: "https://api.deepseek.com".into(),
api_path: None,
enabled: true,
},
)
.await
.unwrap();
gateway::record_usage(db, &key.id, &provider.id, Some("deepseek-chat"), 1500, 500)
.await
.unwrap();
(h, key, provider)
}
#[tokio::test]
async fn gateway_usage_reports_split_request_and_response_tokens() {
let (h, key, provider) = seed_gateway_usage().await;
let db = &h.conn;
let metrics = gateway::get_gateway_metrics(db).await.unwrap();
assert_eq!(metrics.total_requests, 1);
assert_eq!(metrics.total_tokens, 2000);
assert_eq!(metrics.total_request_tokens, 1500);
assert_eq!(metrics.total_response_tokens, 500);
assert_eq!(metrics.today_requests, 1);
assert_eq!(metrics.today_tokens, 2000);
assert_eq!(metrics.today_request_tokens, 1500);
assert_eq!(metrics.today_response_tokens, 500);
let by_key = gateway::get_usage_by_key(db).await.unwrap();
assert_eq!(by_key.len(), 1);
assert_eq!(by_key[0].key_id, key.id);
assert_eq!(by_key[0].request_count, 1);
assert_eq!(by_key[0].token_count, 2000);
assert_eq!(by_key[0].request_tokens, 1500);
assert_eq!(by_key[0].response_tokens, 500);
let by_provider = gateway::get_usage_by_provider(db).await.unwrap();
assert_eq!(by_provider.len(), 1);
assert_eq!(by_provider[0].provider_id, provider.id);
assert_eq!(by_provider[0].request_count, 1);
assert_eq!(by_provider[0].token_count, 2000);
assert_eq!(by_provider[0].request_tokens, 1500);
assert_eq!(by_provider[0].response_tokens, 500);
let by_day = gateway::get_usage_by_day(db, 30).await.unwrap();
assert_eq!(by_day.len(), 1);
assert_eq!(by_day[0].request_count, 1);
assert_eq!(by_day[0].token_count, 2000);
assert_eq!(by_day[0].request_tokens, 1500);
assert_eq!(by_day[0].response_tokens, 500);
}
#[tokio::test]
async fn connected_programs_report_split_today_tokens() {
let (h, key, _provider) = seed_gateway_usage().await;
let db = &h.conn;
let programs = gateway::get_connected_programs(db).await.unwrap();
let program = programs.iter().find(|p| p.key_id == key.id).unwrap();
assert_eq!(program.today_requests, 1);
assert_eq!(program.today_tokens, 2000);
assert_eq!(program.today_request_tokens, 1500);
assert_eq!(program.today_response_tokens, 500);
}
@@ -0,0 +1,681 @@
use boosapi_core::db::create_test_pool;
use boosapi_core::repo::{app, category, conversation, message, provider, settings, stored_file};
use boosapi_core::types::*;
use boosapi_core::utils::gen_id;
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_settings_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
// set + get
settings::set_setting(db, "theme", "dark").await.unwrap();
let val = settings::get_setting(db, "theme").await.unwrap();
assert_eq!(val, Some("dark".to_string()));
// overwrite
settings::set_setting(db, "theme", "light").await.unwrap();
let val = settings::get_setting(db, "theme").await.unwrap();
assert_eq!(val, Some("light".to_string()));
// missing key
let missing = settings::get_setting(db, "nonexistent").await.unwrap();
assert_eq!(missing, None);
}
#[tokio::test]
async fn test_app_settings_roundtrip() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let mut s = AppSettings::default();
s.theme_mode = "dark".into();
s.font_size = 16;
settings::save_settings(db, &s).await.unwrap();
let loaded = settings::get_settings(db).await.unwrap();
assert_eq!(loaded.theme_mode, "dark");
assert_eq!(loaded.font_size, 16);
}
// ---------------------------------------------------------------------------
// Category
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_category_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
// create
let cat = category::create_category(db, "Work").await.unwrap();
assert_eq!(cat.name, "Work");
assert!(!cat.id.is_empty());
// list
let cats = category::list_categories(db).await.unwrap();
assert_eq!(cats.len(), 1);
assert_eq!(cats[0].name, "Work");
// update
let updated = category::update_category(db, &cat.id, "Personal")
.await
.unwrap();
assert_eq!(updated.name, "Personal");
// delete
category::delete_category(db, &cat.id).await.unwrap();
let cats = category::list_categories(db).await.unwrap();
assert!(cats.is_empty());
}
#[tokio::test]
async fn test_category_reorder() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let a = category::create_category(db, "A").await.unwrap();
let b = category::create_category(db, "B").await.unwrap();
let c = category::create_category(db, "C").await.unwrap();
// reverse order
let ids = vec![c.id.clone(), b.id.clone(), a.id.clone()];
category::reorder_categories(db, &ids).await.unwrap();
let cats = category::list_categories(db).await.unwrap();
assert_eq!(cats[0].id, c.id);
assert_eq!(cats[1].id, b.id);
assert_eq!(cats[2].id, a.id);
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_provider_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
// count built-in seed providers
let seed_count = provider::list_providers(db).await.unwrap().len();
// create
let input = CreateProviderInput {
name: "OpenAI".into(),
provider_type: ProviderType::OpenAI,
api_host: "https://api.openai.com".into(),
api_path: None,
enabled: true,
};
let prov = provider::create_provider(db, input).await.unwrap();
assert_eq!(prov.name, "OpenAI");
assert_eq!(prov.provider_type, ProviderType::OpenAI);
assert!(prov.enabled);
// list
let provs = provider::list_providers(db).await.unwrap();
assert_eq!(provs.len(), seed_count + 1);
// update
let update = UpdateProviderInput {
name: Some("OpenAI v2".into()),
api_host: None,
api_path: None,
enabled: None,
proxy_config: None,
sort_order: None,
};
let updated = provider::update_provider(db, &prov.id, update)
.await
.unwrap();
assert_eq!(updated.name, "OpenAI v2");
// toggle
provider::toggle_provider(db, &prov.id, false)
.await
.unwrap();
let fetched = provider::get_provider(db, &prov.id).await.unwrap();
assert!(!fetched.enabled);
// delete
provider::delete_provider(db, &prov.id).await.unwrap();
let provs = provider::list_providers(db).await.unwrap();
assert_eq!(provs.len(), seed_count);
}
#[tokio::test]
async fn test_provider_key_operations() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let prov = provider::create_provider(
db,
CreateProviderInput {
name: "Test".into(),
provider_type: ProviderType::Custom,
api_host: "https://example.com".into(),
api_path: None,
enabled: true,
},
)
.await
.unwrap();
// add key
let key = provider::add_provider_key(db, &prov.id, "enc_key_data", "sk-abc")
.await
.unwrap();
assert_eq!(key.provider_id, prov.id);
assert_eq!(key.key_prefix, "sk-abc");
assert!(key.enabled);
// list keys
let keys = provider::list_keys_for_provider(db, &prov.id)
.await
.unwrap();
assert_eq!(keys.len(), 1);
// get key
let fetched = provider::get_provider_key(db, &key.id).await.unwrap();
assert_eq!(fetched.id, key.id);
// toggle key
provider::toggle_provider_key(db, &key.id, false)
.await
.unwrap();
let fetched = provider::get_provider_key(db, &key.id).await.unwrap();
assert!(!fetched.enabled);
// delete key
provider::delete_provider_key(db, &key.id).await.unwrap();
let keys = provider::list_keys_for_provider(db, &prov.id)
.await
.unwrap();
assert!(keys.is_empty());
}
#[tokio::test]
async fn test_provider_model_operations() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let prov = provider::create_provider(
db,
CreateProviderInput {
name: "Anthropic".into(),
provider_type: ProviderType::Anthropic,
api_host: "https://api.anthropic.com".into(),
api_path: None,
enabled: true,
},
)
.await
.unwrap();
let models = vec![Model {
provider_id: prov.id.clone(),
model_id: "claude-3".into(),
name: "Claude 3".into(),
group_name: Some("claude-3".into()),
model_type: ModelType::Chat,
capabilities: vec![ModelCapability::TextChat],
max_tokens: Some(4096),
enabled: true,
param_overrides: None,
}];
// save models
provider::save_models(db, &prov.id, &models).await.unwrap();
// list models
let listed = provider::list_models_for_provider(db, &prov.id)
.await
.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].model_id, "claude-3");
assert_eq!(listed[0].group_name.as_deref(), Some("claude-3"));
// get model
let m = provider::get_model(db, &prov.id, "claude-3").await.unwrap();
assert_eq!(m.name, "Claude 3");
assert_eq!(m.group_name.as_deref(), Some("claude-3"));
// toggle model
let toggled = provider::toggle_model(db, &prov.id, "claude-3", false)
.await
.unwrap();
assert!(!toggled.enabled);
}
// ---------------------------------------------------------------------------
// Conversation
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_conversation_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
// create
let conv =
conversation::create_conversation(db, "Hello World", "gpt-4", "openai-1", None, None)
.await
.unwrap();
assert_eq!(conv.title, "Hello World");
assert_eq!(conv.model_id, "gpt-4");
assert_eq!(conv.provider_id, "openai-1");
// list
let convs = conversation::list_conversations(db).await.unwrap();
assert_eq!(convs.len(), 1);
// get
let fetched = conversation::get_conversation(db, &conv.id).await.unwrap();
assert_eq!(fetched.id, conv.id);
// update title
conversation::update_conversation_title(db, &conv.id, "Updated Title")
.await
.unwrap();
let fetched = conversation::get_conversation(db, &conv.id).await.unwrap();
assert_eq!(fetched.title, "Updated Title");
// pin toggle
let pinned = conversation::toggle_pin(db, &conv.id).await.unwrap();
assert!(pinned.is_pinned);
let unpinned = conversation::toggle_pin(db, &conv.id).await.unwrap();
assert!(!unpinned.is_pinned);
// message count
conversation::increment_message_count(db, &conv.id)
.await
.unwrap();
conversation::increment_message_count(db, &conv.id)
.await
.unwrap();
let fetched = conversation::get_conversation(db, &conv.id).await.unwrap();
assert_eq!(fetched.message_count, 2);
conversation::decrement_message_count(db, &conv.id)
.await
.unwrap();
let fetched = conversation::get_conversation(db, &conv.id).await.unwrap();
assert_eq!(fetched.message_count, 1);
// delete
conversation::delete_conversation(db, &conv.id)
.await
.unwrap();
let convs = conversation::list_conversations(db).await.unwrap();
assert!(convs.is_empty());
}
#[tokio::test]
async fn test_conversation_update_input() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Chat", "gpt-4", "p1", None, None)
.await
.unwrap();
let input = UpdateConversationInput {
title: Some("New Chat".into()),
provider_id: Some("p2".into()),
model_id: Some("gpt-4o".into()),
is_pinned: Some(true),
is_archived: None,
system_prompt: None,
temperature: None,
max_tokens: None,
top_p: None,
frequency_penalty: None,
search_enabled: Some(true),
search_provider_id: Some(Some("search-1".into())),
thinking_budget: Some(Some(4096)),
thinking_level: Some(Some("high".into())),
enabled_mcp_server_ids: Some(vec!["mcp-a".into(), "mcp-b".into()]),
enabled_knowledge_base_ids: Some(vec!["kb-a".into()]),
enabled_memory_namespace_ids: Some(vec!["mem-a".into()]),
};
let updated = conversation::update_conversation(db, &conv.id, input)
.await
.unwrap();
assert_eq!(updated.title, "New Chat");
assert_eq!(updated.provider_id, "p2");
assert_eq!(updated.model_id, "gpt-4o");
assert!(updated.is_pinned);
assert!(updated.search_enabled);
assert_eq!(updated.search_provider_id.as_deref(), Some("search-1"));
assert_eq!(updated.thinking_budget, Some(4096));
assert_eq!(updated.thinking_level.as_deref(), Some("high"));
assert_eq!(
updated.enabled_mcp_server_ids,
vec!["mcp-a".to_string(), "mcp-b".to_string()]
);
assert_eq!(updated.enabled_knowledge_base_ids, vec!["kb-a".to_string()]);
assert_eq!(
updated.enabled_memory_namespace_ids,
vec!["mem-a".to_string()]
);
}
// ---------------------------------------------------------------------------
// Message
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_message_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Test Chat", "model-1", "prov-1", None, None)
.await
.unwrap();
// create message (no attachments)
let msg = message::create_message(db, &conv.id, MessageRole::User, "Hello!", &[], None, 0)
.await
.unwrap();
assert_eq!(msg.conversation_id, conv.id);
assert_eq!(msg.role, MessageRole::User);
assert_eq!(msg.content, "Hello!");
// create assistant reply
let reply = message::create_message(
db,
&conv.id,
MessageRole::Assistant,
"Hi there!",
&[],
None,
0,
)
.await
.unwrap();
assert_eq!(reply.role, MessageRole::Assistant);
// list messages
let msgs = message::list_messages(db, &conv.id).await.unwrap();
assert_eq!(msgs.len(), 2);
// update content
let updated = message::update_message_content(db, &msg.id, "Hello, World!")
.await
.unwrap();
assert_eq!(updated.content, "Hello, World!");
// delete single message
message::delete_message(db, &reply.id).await.unwrap();
let msgs = message::list_messages(db, &conv.id).await.unwrap();
assert_eq!(msgs.len(), 1);
}
#[tokio::test]
async fn test_message_with_attachments() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Attach Chat", "m1", "p1", None, None)
.await
.unwrap();
let attachments = vec![AttachmentInput {
file_name: "doc.pdf".into(),
file_type: "application/pdf".into(),
file_size: 1024,
data: "base64data".into(),
}];
// create_message stores AttachmentInput JSON, but message_from_entity
// deserialises as Vec<Attachment> (different struct) — mismatched fields
// cause unwrap_or_default() to return []. Verify the message is created
// successfully; attachment round-trip fidelity is a known schema gap.
let msg = message::create_message(
db,
&conv.id,
MessageRole::User,
"See attached",
&attachments,
None,
0,
)
.await
.unwrap();
assert_eq!(msg.content, "See attached");
assert_eq!(msg.role, MessageRole::User);
}
#[tokio::test]
async fn test_delete_messages_after() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Branching", "m1", "p1", None, None)
.await
.unwrap();
let _m1 = message::create_message(db, &conv.id, MessageRole::User, "first", &[], None, 0)
.await
.unwrap();
// Use a future timestamp so only messages created at or after it are deleted.
// Since both messages may share the same second-resolution timestamp, we
// create m2, record its timestamp, then delete from that point onward.
let m2 = message::create_message(db, &conv.id, MessageRole::Assistant, "second", &[], None, 0)
.await
.unwrap();
// If both messages share the same timestamp, deleting from m2.created_at
// will remove both. Query first to figure out what we expect.
let before = message::list_messages(db, &conv.id).await.unwrap();
let expected_remaining = before
.iter()
.filter(|m| m.created_at < m2.created_at)
.count();
let deleted = message::delete_messages_after(db, &conv.id, m2.created_at)
.await
.unwrap();
assert!(deleted >= 1);
let remaining = message::list_messages(db, &conv.id).await.unwrap();
assert_eq!(remaining.len(), expected_remaining);
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_app_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
// create
let input = CreateAppInput {
name: "Translator".into(),
description: Some("Translates text".into()),
icon: Some("🌐".into()),
icon_color: Some("#ff0000".into()),
system_prompt: Some("You are a translator.".into()),
default_model_id: None,
default_provider_id: None,
temperature: Some(0.7),
max_tokens: None,
top_p: None,
category_id: None,
variables: Some(vec![Variable {
name: "target_lang".into(),
label: "Target Language".into(),
default_value: "English".into(),
placeholder: "Enter language".into(),
}]),
};
let a = app::create_app(db, input).await.unwrap();
assert_eq!(a.name, "Translator");
assert_eq!(a.description, "Translates text");
assert_eq!(a.variables.len(), 1);
assert_eq!(a.variables[0].name, "target_lang");
// list
let apps = app::list_apps(db).await.unwrap();
assert_eq!(apps.len(), 1);
// get
let fetched = app::get_app(db, &a.id).await.unwrap();
assert_eq!(fetched.id, a.id);
// update
let update = UpdateAppInput {
name: Some("Super Translator".into()),
description: None,
icon: None,
icon_color: None,
system_prompt: None,
default_model_id: None,
default_provider_id: None,
temperature: None,
max_tokens: None,
top_p: None,
category_id: None,
variables: None,
};
let updated = app::update_app(db, &a.id, update).await.unwrap();
assert_eq!(updated.name, "Super Translator");
// toggle favorite
app::toggle_favorite(db, &a.id).await.unwrap();
let fetched = app::get_app(db, &a.id).await.unwrap();
assert!(fetched.is_favorite);
// delete
app::delete_app(db, &a.id).await.unwrap();
let apps = app::list_apps(db).await.unwrap();
assert!(apps.is_empty());
}
// ---------------------------------------------------------------------------
// Stored File
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_stored_file_crud() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let file_id = gen_id();
let conv = conversation::create_conversation(db, "File Chat", "m1", "p1", None, None)
.await
.unwrap();
// create
let sf = stored_file::create_stored_file(
db,
&file_id,
"abc123hash",
"photo.jpg",
"image/jpeg",
2048,
"/storage/photo.jpg",
Some(&conv.id),
)
.await
.unwrap();
assert_eq!(sf.id, file_id);
assert_eq!(sf.original_name, "photo.jpg");
assert_eq!(sf.size_bytes, 2048);
assert_eq!(sf.conversation_id.as_deref(), Some(conv.id.as_str()));
// get
let fetched = stored_file::get_stored_file(db, &file_id).await.unwrap();
assert_eq!(fetched.hash, "abc123hash");
// list by conversation
let files = stored_file::list_stored_files_by_conversation(db, &conv.id)
.await
.unwrap();
assert_eq!(files.len(), 1);
// find by hash
let found = stored_file::find_by_hash(db, "abc123hash").await.unwrap();
assert!(found.is_some());
let not_found = stored_file::find_by_hash(db, "nonexistent").await.unwrap();
assert!(not_found.is_none());
// delete
stored_file::delete_stored_file(db, &file_id).await.unwrap();
let files = stored_file::list_stored_files_by_conversation(db, &conv.id)
.await
.unwrap();
assert!(files.is_empty());
}
#[tokio::test]
async fn test_stored_file_no_conversation() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let file_id = gen_id();
let sf = stored_file::create_stored_file(
db,
&file_id,
"hash999",
"standalone.txt",
"text/plain",
512,
"/storage/standalone.txt",
None,
)
.await
.unwrap();
assert!(sf.conversation_id.is_none());
stored_file::delete_stored_file(db, &file_id).await.unwrap();
}
#[tokio::test]
async fn test_delete_stored_files_by_conversation() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let conv = conversation::create_conversation(db, "Bulk Files", "m1", "p1", None, None)
.await
.unwrap();
for i in 0..3 {
let id = gen_id();
stored_file::create_stored_file(
db,
&id,
&format!("hash{i}"),
&format!("file{i}.txt"),
"text/plain",
100,
&format!("/storage/file{i}.txt"),
Some(&conv.id),
)
.await
.unwrap();
}
let files = stored_file::list_stored_files_by_conversation(db, &conv.id)
.await
.unwrap();
assert_eq!(files.len(), 3);
stored_file::delete_stored_files_by_conversation(db, &conv.id)
.await
.unwrap();
let files = stored_file::list_stored_files_by_conversation(db, &conv.id)
.await
.unwrap();
assert!(files.is_empty());
}