test: added more tests (#16)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m47s
CI / Security Audit (push) Successful in 1m35s
CI / Tests (push) Successful in 3m54s
CI / E2E Tests (push) Failing after 16s
CI / Deploy (push) Has been skipped

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-02-25 10:01:56 +00:00
parent 9085da9fae
commit 1d7aebf37c
29 changed files with 2243 additions and 22 deletions

View File

@@ -24,9 +24,9 @@ pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
/// post-login redirect URL and the PKCE code verifier needed for the
/// token exchange.
#[derive(Debug, Clone)]
struct PendingOAuthEntry {
redirect_url: Option<String>,
code_verifier: String,
pub(crate) struct PendingOAuthEntry {
pub(crate) redirect_url: Option<String>,
pub(crate) code_verifier: String,
}
/// In-memory store for pending OAuth states. Keyed by the random state
@@ -38,7 +38,7 @@ pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
impl PendingOAuthStore {
/// Insert a pending state with an optional redirect URL and PKCE verifier.
fn insert(&self, state: String, entry: PendingOAuthEntry) {
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
// RwLock::write only panics if the lock is poisoned, which
// indicates a prior panic -- propagating is acceptable here.
#[allow(clippy::expect_used)]
@@ -50,7 +50,7 @@ impl PendingOAuthStore {
/// Remove and return the entry if the state was pending.
/// Returns `None` if the state was never stored (CSRF failure).
fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
#[allow(clippy::expect_used)]
self.0
.write()
@@ -60,7 +60,8 @@ impl PendingOAuthStore {
}
/// Generate a cryptographically random state string for CSRF protection.
fn generate_state() -> String {
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn generate_state() -> String {
let bytes: [u8; 32] = rand::rng().random();
// Encode as hex to produce a URL-safe string without padding.
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
@@ -75,7 +76,7 @@ fn generate_state() -> String {
///
/// Uses 32 random bytes encoded as base64url (no padding) to produce
/// a 43-character verifier per RFC 7636.
fn generate_code_verifier() -> String {
pub(crate) fn generate_code_verifier() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let bytes: [u8; 32] = rand::rng().random();
@@ -85,7 +86,7 @@ fn generate_code_verifier() -> String {
/// Derive the S256 code challenge from a code verifier per RFC 7636.
///
/// `code_challenge = BASE64URL(SHA256(code_verifier))`
fn derive_code_challenge(verifier: &str) -> String {
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use sha2::{Digest, Sha256};
@@ -304,3 +305,117 @@ pub async fn set_login_session(session: Session, data: UserStateInner) -> Result
.await
.map_err(|e| Error::StateError(format!("session insert failed: {e}")))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// generate_state()
// -----------------------------------------------------------------------
#[test]
fn generate_state_length_is_64() {
let state = generate_state();
assert_eq!(state.len(), 64);
}
#[test]
fn generate_state_chars_are_hex() {
let state = generate_state();
assert!(state.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn generate_state_two_calls_differ() {
let a = generate_state();
let b = generate_state();
assert_ne!(a, b);
}
// -----------------------------------------------------------------------
// generate_code_verifier()
// -----------------------------------------------------------------------
#[test]
fn code_verifier_length_is_43() {
let verifier = generate_code_verifier();
assert_eq!(verifier.len(), 43);
}
#[test]
fn code_verifier_chars_are_url_safe_base64() {
let verifier = generate_code_verifier();
// URL-safe base64 without padding uses [A-Za-z0-9_-]
assert!(verifier
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
// -----------------------------------------------------------------------
// derive_code_challenge()
// -----------------------------------------------------------------------
#[test]
fn code_challenge_deterministic() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let a = derive_code_challenge(verifier);
let b = derive_code_challenge(verifier);
assert_eq!(a, b);
}
#[test]
fn code_challenge_rfc7636_test_vector() {
// RFC 7636 Appendix B test vector:
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// expected challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let challenge = derive_code_challenge(verifier);
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
// -----------------------------------------------------------------------
// PendingOAuthStore
// -----------------------------------------------------------------------
#[test]
fn pending_store_insert_and_take() {
let store = PendingOAuthStore::default();
store.insert(
"state-1".into(),
PendingOAuthEntry {
redirect_url: Some("/dashboard".into()),
code_verifier: "verifier-1".into(),
},
);
let entry = store.take("state-1");
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.redirect_url, Some("/dashboard".into()));
assert_eq!(entry.code_verifier, "verifier-1");
}
#[test]
fn pending_store_take_removes_entry() {
let store = PendingOAuthStore::default();
store.insert(
"state-2".into(),
PendingOAuthEntry {
redirect_url: None,
code_verifier: "v2".into(),
},
);
let _ = store.take("state-2");
// Second take should return None since the entry was removed.
assert!(store.take("state-2").is_none());
}
#[test]
fn pending_store_take_unknown_returns_none() {
let store = PendingOAuthStore::default();
assert!(store.take("nonexistent").is_none());
}
}

View File

@@ -440,7 +440,12 @@ pub async fn chat_complete(
let session = doc_to_chat_session(&session_doc);
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(&state, &session.provider, &session.model);
let (base_url, model) = resolve_provider_url(
&state.services.ollama_url,
&state.services.ollama_model,
&session.provider,
&session.model,
);
// Parse messages from JSON
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
@@ -480,10 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to server defaults.
/// Resolve the base URL for a provider, falling back to Ollama defaults.
///
/// # Arguments
///
/// * `ollama_url` - Default Ollama base URL from config
/// * `ollama_model` - Default Ollama model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for Ollama default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
fn resolve_provider_url(
state: &crate::infrastructure::ServerState,
pub(crate) fn resolve_provider_url(
ollama_url: &str,
ollama_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -496,12 +513,229 @@ fn resolve_provider_url(
),
// Default to Ollama
_ => (
state.services.ollama_url.clone(),
ollama_url.to_string(),
if model.is_empty() {
state.services.ollama_model.clone()
ollama_model.to_string()
} else {
model.to_string()
},
),
}
}
#[cfg(test)]
mod tests {
// -----------------------------------------------------------------------
// BSON document conversion tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
use crate::models::{ChatNamespace, ChatRole};
use mongodb::bson::{doc, oid::ObjectId, Document};
use pretty_assertions::assert_eq;
// -- doc_to_chat_session --
fn sample_session_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "user-42",
"title": "Test Session",
"namespace": "News",
"provider": "openai",
"model": "gpt-4",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-02T00:00:00Z",
"article_url": "https://example.com/article",
};
(oid, doc)
}
#[test]
fn doc_to_chat_session_extracts_id_as_hex() {
let (oid, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.id, oid.to_hex());
}
#[test]
fn doc_to_chat_session_maps_news_namespace() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::News);
}
#[test]
fn doc_to_chat_session_defaults_to_general_for_unknown() {
let mut doc = sample_session_doc().1;
doc.insert("namespace", "SomethingElse");
let session = doc_to_chat_session(&doc);
assert_eq!(session.namespace, ChatNamespace::General);
}
#[test]
fn doc_to_chat_session_extracts_all_string_fields() {
let (_, doc) = sample_session_doc();
let session = doc_to_chat_session(&doc);
assert_eq!(session.user_sub, "user-42");
assert_eq!(session.title, "Test Session");
assert_eq!(session.provider, "openai");
assert_eq!(session.model, "gpt-4");
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
}
#[test]
fn doc_to_chat_session_handles_missing_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
#[test]
fn doc_to_chat_session_filters_empty_article_url() {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "ollama",
"model": "m",
"created_at": "c",
"updated_at": "u",
"article_url": "",
};
let session = doc_to_chat_session(&doc);
assert_eq!(session.article_url, None);
}
// -- doc_to_chat_message --
fn sample_message_doc() -> (ObjectId, Document) {
let oid = ObjectId::new();
let doc = doc! {
"_id": oid,
"session_id": "sess-1",
"role": "Assistant",
"content": "Hello there!",
"timestamp": "2025-01-01T12:00:00Z",
};
(oid, doc)
}
#[test]
fn doc_to_chat_message_extracts_id_as_hex() {
let (oid, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.id, oid.to_hex());
}
#[test]
fn doc_to_chat_message_maps_assistant_role() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::Assistant);
}
#[test]
fn doc_to_chat_message_maps_system_role() {
let mut doc = sample_message_doc().1;
doc.insert("role", "System");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::System);
}
#[test]
fn doc_to_chat_message_defaults_to_user_for_unknown() {
let mut doc = sample_message_doc().1;
doc.insert("role", "SomethingElse");
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.role, ChatRole::User);
}
#[test]
fn doc_to_chat_message_extracts_content_and_timestamp() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert_eq!(msg.content, "Hello there!");
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
assert_eq!(msg.session_id, "sess-1");
}
#[test]
fn doc_to_chat_message_attachments_always_empty() {
let (_, doc) = sample_message_doc();
let msg = doc_to_chat_message(&doc);
assert!(msg.attachments.is_empty());
}
// -- resolve_provider_url --
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"anthropic",
"claude-3-opus",
);
assert_eq!(url, "https://api.anthropic.com");
assert_eq!(model, "claude-3-opus");
}
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
assert_eq!(
url,
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
);
assert_eq!(model, "meta-llama/Llama-2-7b");
}
#[test]
fn resolve_unknown_defaults_to_ollama() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "mistral:7b");
}
#[test]
fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_OLLAMA_MODEL);
}
}
}

View File

@@ -251,3 +251,160 @@ impl LlmProvidersConfig {
Ok(Self { providers })
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use pretty_assertions::assert_eq;
use serial_test::serial;
// -----------------------------------------------------------------------
// KeycloakConfig endpoint methods (no env vars needed)
// -----------------------------------------------------------------------
fn sample_keycloak() -> KeycloakConfig {
KeycloakConfig {
url: "https://auth.example.com".into(),
realm: "myrealm".into(),
client_id: "dashboard".into(),
redirect_uri: "https://app.example.com/callback".into(),
app_url: "https://app.example.com".into(),
admin_client_id: String::new(),
admin_client_secret: SecretString::from(String::new()),
}
}
#[test]
fn keycloak_auth_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.auth_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/auth"
);
}
#[test]
fn keycloak_token_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.token_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
);
}
#[test]
fn keycloak_userinfo_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.userinfo_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo"
);
}
#[test]
fn keycloak_logout_endpoint() {
let kc = sample_keycloak();
assert_eq!(
kc.logout_endpoint(),
"https://auth.example.com/realms/myrealm/protocol/openid-connect/logout"
);
}
// -----------------------------------------------------------------------
// LlmProvidersConfig::from_env()
// -----------------------------------------------------------------------
#[test]
#[serial]
fn llm_providers_empty_string() {
std::env::set_var("LLM_PROVIDERS", "");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert!(cfg.providers.is_empty());
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "ollama");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_multiple() {
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_trims_whitespace() {
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_filters_empty_entries() {
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
// -----------------------------------------------------------------------
// ServiceUrls::from_env() defaults
// -----------------------------------------------------------------------
#[test]
#[serial]
fn service_urls_default_ollama_url() {
std::env::remove_var("OLLAMA_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://localhost:11434");
}
#[test]
#[serial]
fn service_urls_default_ollama_model() {
std::env::remove_var("OLLAMA_MODEL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_model, "llama3.1:8b");
}
#[test]
#[serial]
fn service_urls_default_searxng_url() {
std::env::remove_var("SEARXNG_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.searxng_url, "http://localhost:8888");
}
#[test]
#[serial]
fn service_urls_custom_ollama_url() {
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
std::env::remove_var("OLLAMA_URL");
}
#[test]
#[serial]
fn required_env_missing_returns_config_error() {
std::env::remove_var("__TEST_REQUIRED_MISSING__");
let result = required_env("__TEST_REQUIRED_MISSING__");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("__TEST_REQUIRED_MISSING__"));
}
}

View File

@@ -41,3 +41,53 @@ impl IntoResponse for Error {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;
use pretty_assertions::assert_eq;
#[test]
fn state_error_display() {
let err = Error::StateError("bad state".into());
assert_eq!(err.to_string(), "bad state");
}
#[test]
fn database_error_display() {
let err = Error::DatabaseError("connection lost".into());
assert_eq!(err.to_string(), "database error: connection lost");
}
#[test]
fn config_error_display() {
let err = Error::ConfigError("missing var".into());
assert_eq!(err.to_string(), "configuration error: missing var");
}
#[test]
fn state_error_into_response_500() {
let resp = Error::StateError("oops".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn database_error_into_response_503() {
let resp = Error::DatabaseError("down".into()).into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn config_error_into_response_500() {
let resp = Error::ConfigError("bad cfg".into()).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn io_error_into_response_500() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let resp = Error::IoError(io_err).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}

View File

@@ -72,7 +72,25 @@ mod inner {
}
let html = resp.text().await.ok()?;
let document = scraper::Html::parse_document(&html);
parse_article_html(&html)
}
/// Parse article text from raw HTML without any network I/O.
///
/// Uses a tiered extraction strategy:
/// 1. Try content within `<article>`, `<main>`, or `[role="main"]`
/// 2. Fall back to all `<p>` tags outside excluded containers
///
/// # Arguments
///
/// * `html` - Raw HTML string to parse
///
/// # Returns
///
/// The extracted text, or `None` if extraction yields < 100 chars.
/// Output is capped at 8000 characters.
pub(crate) fn parse_article_html(html: &str) -> Option<String> {
let document = scraper::Html::parse_document(html);
// Strategy 1: Extract from semantic article containers.
// Most news sites wrap the main content in <article>, <main>,
@@ -134,7 +152,7 @@ mod inner {
}
/// Sum the total character length of all collected text parts.
fn joined_len(parts: &[String]) -> usize {
pub(crate) fn joined_len(parts: &[String]) -> usize {
parts.iter().map(|s| s.len()).sum()
}
}
@@ -325,3 +343,150 @@ pub async fn chat_followup(
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// FollowUpMessage serde tests
// -----------------------------------------------------------------------
#[test]
fn followup_message_serde_round_trip() {
let msg = FollowUpMessage {
role: "assistant".into(),
content: "Here is my answer.".into(),
};
let json = serde_json::to_string(&msg).expect("serialize FollowUpMessage");
let back: FollowUpMessage =
serde_json::from_str(&json).expect("deserialize FollowUpMessage");
assert_eq!(msg, back);
}
#[test]
fn followup_message_deserialize_from_json_literal() {
let json = r#"{"role":"system","content":"You are helpful."}"#;
let msg: FollowUpMessage = serde_json::from_str(json).expect("deserialize literal");
assert_eq!(msg.role, "system");
assert_eq!(msg.content, "You are helpful.");
}
// -----------------------------------------------------------------------
// joined_len and parse_article_html tests (server feature required)
// -----------------------------------------------------------------------
#[cfg(feature = "server")]
mod server_tests {
use super::super::inner::{joined_len, parse_article_html};
use pretty_assertions::assert_eq;
#[test]
fn joined_len_empty_input() {
assert_eq!(joined_len(&[]), 0);
}
#[test]
fn joined_len_sums_correctly() {
let parts = vec!["abc".into(), "de".into(), "fghij".into()];
assert_eq!(joined_len(&parts), 10);
}
// -------------------------------------------------------------------
// parse_article_html tests
// -------------------------------------------------------------------
// Helper: generate a string of given length from a repeated word.
fn lorem(len: usize) -> String {
"Lorem ipsum dolor sit amet consectetur adipiscing elit "
.repeat((len / 55) + 1)
.chars()
.take(len)
.collect()
}
#[test]
fn article_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for article tag");
assert!(result.unwrap().contains("Lorem"));
}
#[test]
fn main_tag_extracts_text() {
let body = lorem(250);
let html = format!("<html><body><main><p>{body}</p></main></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for main tag");
}
#[test]
fn fallback_to_p_tags_when_article_main_yield_little() {
// No <article>/<main>, so falls back to <p> tags
let body = lorem(250);
let html = format!("<html><body><div><p>{body}</p></div></body></html>");
let result = parse_article_html(&html);
assert!(result.is_some(), "expected fallback to <p> tags");
}
#[test]
fn excludes_nav_footer_aside_content() {
// Content only inside excluded containers -- should be excluded
let body = lorem(250);
let html = format!(
"<html><body>\
<nav><p>{body}</p></nav>\
<footer><p>{body}</p></footer>\
<aside><p>{body}</p></aside>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_none(), "expected None for excluded-only content");
}
#[test]
fn returns_none_when_text_too_short() {
let html = "<html><body><p>Short.</p></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for short text");
}
#[test]
fn truncates_at_8000_chars() {
let body = lorem(10000);
let html = format!("<html><body><article><p>{body}</p></article></body></html>");
let result = parse_article_html(&html).expect("expected Some");
assert!(
result.len() <= 8000,
"expected <= 8000 chars, got {}",
result.len()
);
}
#[test]
fn skips_fragments_under_30_chars() {
// Only fragments < 30 chars -- should yield None
let html = "<html><body><article>\
<p>Short frag one</p>\
<p>Another tiny bit</p>\
</article></body></html>";
let result = parse_article_html(html);
assert!(result.is_none(), "expected None for tiny fragments");
}
#[test]
fn extracts_from_role_main_attribute() {
let body = lorem(250);
let html = format!(
"<html><body>\
<div role=\"main\"><p>{body}</p></div>\
</body></html>"
);
let result = parse_article_html(&html);
assert!(result.is_some(), "expected Some for role=main");
}
}
}

View File

@@ -146,3 +146,30 @@ pub async fn send_chat_request(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn provider_message_serde_round_trip() {
let msg = ProviderMessage {
role: "assistant".into(),
content: "Hello, world!".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ProviderMessage");
let back: ProviderMessage =
serde_json::from_str(&json).expect("deserialize ProviderMessage");
assert_eq!(msg.role, back.role);
assert_eq!(msg.content, back.content);
}
#[test]
fn provider_message_deserialize_from_json_literal() {
let json = r#"{"role":"user","content":"What is Rust?"}"#;
let msg: ProviderMessage = serde_json::from_str(json).expect("deserialize from literal");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "What is Rust?");
}
}

View File

@@ -5,13 +5,13 @@ use dioxus::prelude::*;
// The #[server] macro generates a client stub for the web build that
// sends a network request instead of executing this function body.
#[cfg(feature = "server")]
mod inner {
pub(crate) mod inner {
use serde::Deserialize;
use std::collections::HashSet;
/// Individual result from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResult {
pub(crate) struct SearxngResult {
pub title: String,
pub url: String,
pub content: Option<String>,
@@ -25,7 +25,7 @@ mod inner {
/// Top-level response from the SearXNG search API.
#[derive(Debug, Deserialize)]
pub(super) struct SearxngResponse {
pub(crate) struct SearxngResponse {
pub results: Vec<SearxngResult>,
}
@@ -40,7 +40,7 @@ mod inner {
/// # Returns
///
/// The domain host or a fallback "Web" string
pub(super) fn extract_source(url_str: &str) -> String {
pub(crate) fn extract_source(url_str: &str) -> String {
url::Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(String::from))
@@ -64,7 +64,7 @@ mod inner {
/// # Returns
///
/// Filtered, deduplicated, and ranked results
pub(super) fn rank_and_deduplicate(
pub(crate) fn rank_and_deduplicate(
mut results: Vec<SearxngResult>,
max_results: usize,
) -> Vec<SearxngResult> {
@@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result<Vec<String>, ServerFnError> {
Ok(topics)
}
#[cfg(all(test, feature = "server"))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::inner::*;
use pretty_assertions::assert_eq;
// -----------------------------------------------------------------------
// extract_source()
// -----------------------------------------------------------------------
#[test]
fn extract_source_strips_www() {
assert_eq!(
extract_source("https://www.example.com/page"),
"example.com"
);
}
#[test]
fn extract_source_returns_domain() {
assert_eq!(
extract_source("https://techcrunch.com/article"),
"techcrunch.com"
);
}
#[test]
fn extract_source_invalid_url_returns_web() {
assert_eq!(extract_source("not-a-url"), "Web");
}
#[test]
fn extract_source_no_scheme_returns_web() {
// url::Url::parse requires a scheme; bare domain fails
assert_eq!(extract_source("example.com/path"), "Web");
}
// -----------------------------------------------------------------------
// rank_and_deduplicate()
// -----------------------------------------------------------------------
fn make_result(url: &str, content: &str, score: f64) -> SearxngResult {
SearxngResult {
title: "Title".into(),
url: url.into(),
content: if content.is_empty() {
None
} else {
Some(content.into())
},
published_date: None,
thumbnail: None,
score,
}
}
#[test]
fn rank_filters_empty_content() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result(
"https://b.com",
"This is meaningful content that passes the length filter",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].url, "https://b.com");
}
#[test]
fn rank_filters_short_content() {
let results = vec![
make_result("https://a.com", "short", 10.0),
make_result(
"https://b.com",
"This content is long enough to pass the 20-char filter threshold",
5.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
}
#[test]
fn rank_deduplicates_by_domain_keeps_highest() {
let results = vec![
make_result(
"https://example.com/page1",
"First result with enough content here for the filter",
3.0,
),
make_result(
"https://example.com/page2",
"Second result with enough content here for the filter",
8.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 1);
// Should keep the highest-scored one (page2 with score 8.0)
assert_eq!(ranked[0].url, "https://example.com/page2");
}
#[test]
fn rank_sorts_by_score_descending() {
let results = vec![
make_result(
"https://a.com/p",
"Content A that is long enough to pass the filter check",
1.0,
),
make_result(
"https://b.com/p",
"Content B that is long enough to pass the filter check",
5.0,
),
make_result(
"https://c.com/p",
"Content C that is long enough to pass the filter check",
3.0,
),
];
let ranked = rank_and_deduplicate(results, 10);
assert_eq!(ranked.len(), 3);
assert!(ranked[0].score >= ranked[1].score);
assert!(ranked[1].score >= ranked[2].score);
}
#[test]
fn rank_truncates_to_max_results() {
let results: Vec<_> = (0..20)
.map(|i| {
make_result(
&format!("https://site{i}.com/page"),
&format!("Content for site {i} that is long enough to pass the filter"),
i as f64,
)
})
.collect();
let ranked = rank_and_deduplicate(results, 5);
assert_eq!(ranked.len(), 5);
}
#[test]
fn rank_empty_input_returns_empty() {
let ranked = rank_and_deduplicate(vec![], 10);
assert!(ranked.is_empty());
}
#[test]
fn rank_all_filtered_returns_empty() {
let results = vec![
make_result("https://a.com", "", 10.0),
make_result("https://b.com", "too short", 5.0),
];
let ranked = rank_and_deduplicate(results, 10);
assert!(ranked.is_empty());
}
}

View File

@@ -44,3 +44,91 @@ pub struct User {
/// Avatar / profile picture URL.
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_state_inner_default_has_empty_strings() {
let inner = UserStateInner::default();
assert_eq!(inner.sub, "");
assert_eq!(inner.access_token, "");
assert_eq!(inner.refresh_token, "");
assert_eq!(inner.user.email, "");
assert_eq!(inner.user.name, "");
assert_eq!(inner.user.avatar_url, "");
}
#[test]
fn user_default_has_empty_strings() {
let user = User::default();
assert_eq!(user.email, "");
assert_eq!(user.name, "");
assert_eq!(user.avatar_url, "");
}
#[test]
fn user_state_inner_serde_round_trip() {
let inner = UserStateInner {
sub: "user-123".into(),
access_token: "tok-abc".into(),
refresh_token: "ref-xyz".into(),
user: User {
email: "a@b.com".into(),
name: "Alice".into(),
avatar_url: "https://img.example.com/a.png".into(),
},
};
let json = serde_json::to_string(&inner).expect("serialize UserStateInner");
let back: UserStateInner = serde_json::from_str(&json).expect("deserialize UserStateInner");
assert_eq!(inner.sub, back.sub);
assert_eq!(inner.access_token, back.access_token);
assert_eq!(inner.refresh_token, back.refresh_token);
assert_eq!(inner.user.email, back.user.email);
assert_eq!(inner.user.name, back.user.name);
assert_eq!(inner.user.avatar_url, back.user.avatar_url);
}
#[test]
fn user_state_from_inner_and_deref() {
let inner = UserStateInner {
sub: "sub-1".into(),
access_token: "at".into(),
refresh_token: "rt".into(),
user: User {
email: "e@e.com".into(),
name: "Eve".into(),
avatar_url: "".into(),
},
};
let state = UserState::from(inner);
// Deref should give access to inner fields
assert_eq!(state.sub, "sub-1");
assert_eq!(state.user.name, "Eve");
}
#[test]
fn user_serde_round_trip() {
let user = User {
email: "bob@test.com".into(),
name: "Bob".into(),
avatar_url: "https://avatars.io/bob".into(),
};
let json = serde_json::to_string(&user).expect("serialize User");
let back: User = serde_json::from_str(&json).expect("deserialize User");
assert_eq!(user.email, back.email);
assert_eq!(user.name, back.name);
assert_eq!(user.avatar_url, back.avatar_url);
}
#[test]
fn user_state_clone_is_cheap() {
let inner = UserStateInner::default();
let state = UserState::from(inner);
let cloned = state.clone();
// Both point to the same Arc allocation
assert_eq!(state.sub, cloned.sub);
}
}

View File

@@ -105,3 +105,163 @@ pub struct ChatMessage {
pub attachments: Vec<Attachment>,
pub timestamp: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn chat_namespace_default_is_general() {
assert_eq!(ChatNamespace::default(), ChatNamespace::General);
}
#[test]
fn chat_role_serde_round_trip() {
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
let json =
serde_json::to_string(&role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: ChatRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(role, back);
}
}
#[test]
fn chat_namespace_serde_round_trip() {
for ns in [ChatNamespace::General, ChatNamespace::News] {
let json = serde_json::to_string(&ns).unwrap_or_else(|_| panic!("serialize {:?}", ns));
let back: ChatNamespace =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", ns));
assert_eq!(ns, back);
}
}
#[test]
fn attachment_kind_serde_round_trip() {
for kind in [
AttachmentKind::Image,
AttachmentKind::Document,
AttachmentKind::Code,
] {
let json =
serde_json::to_string(&kind).unwrap_or_else(|_| panic!("serialize {:?}", kind));
let back: AttachmentKind =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", kind));
assert_eq!(kind, back);
}
}
#[test]
fn attachment_serde_round_trip() {
let att = Attachment {
name: "photo.png".into(),
kind: AttachmentKind::Image,
size_bytes: 2048,
};
let json = serde_json::to_string(&att).expect("serialize Attachment");
let back: Attachment = serde_json::from_str(&json).expect("deserialize Attachment");
assert_eq!(att, back);
}
#[test]
fn chat_session_serde_round_trip() {
let session = ChatSession {
id: "abc123".into(),
user_sub: "user-1".into(),
title: "Test Chat".into(),
namespace: ChatNamespace::General,
provider: "ollama".into(),
model: "llama3.1:8b".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize ChatSession");
let back: ChatSession = serde_json::from_str(&json).expect("deserialize ChatSession");
assert_eq!(session, back);
}
#[test]
fn chat_session_id_alias_deserialization() {
// MongoDB returns `_id` instead of `id`
let json = r#"{
"_id": "mongo-id",
"user_sub": "u1",
"title": "t",
"provider": "ollama",
"model": "m",
"created_at": "2025-01-01",
"updated_at": "2025-01-01"
}"#;
let session: ChatSession = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(session.id, "mongo-id");
}
#[test]
fn chat_session_empty_id_skips_serialization() {
let session = ChatSession {
id: String::new(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
// `id` field should be absent when empty due to skip_serializing_if
assert!(!json.contains("\"id\""));
}
#[test]
fn chat_session_none_article_url_skips_serialization() {
let session = ChatSession {
id: "s1".into(),
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
article_url: None,
};
let json = serde_json::to_string(&session).expect("serialize");
assert!(!json.contains("article_url"));
}
#[test]
fn chat_message_serde_round_trip() {
let msg = ChatMessage {
id: "msg-1".into(),
session_id: "s1".into(),
role: ChatRole::User,
content: "Hello AI".into(),
attachments: vec![Attachment {
name: "doc.pdf".into(),
kind: AttachmentKind::Document,
size_bytes: 4096,
}],
timestamp: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&msg).expect("serialize ChatMessage");
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize ChatMessage");
assert_eq!(msg, back);
}
#[test]
fn chat_message_id_alias_deserialization() {
let json = r#"{
"_id": "mongo-msg-id",
"session_id": "s1",
"role": "User",
"content": "hi",
"timestamp": "2025-01-01"
}"#;
let msg: ChatMessage = serde_json::from_str(json).expect("deserialize with _id");
assert_eq!(msg.id, "mongo-msg-id");
}
}

View File

@@ -45,3 +45,63 @@ pub struct AnalyticsMetric {
pub value: String,
pub change_pct: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn agent_entry_serde_round_trip() {
let agent = AgentEntry {
id: "a1".into(),
name: "RAG Agent".into(),
description: "Retrieval-augmented generation".into(),
status: "running".into(),
};
let json = serde_json::to_string(&agent).expect("serialize AgentEntry");
let back: AgentEntry = serde_json::from_str(&json).expect("deserialize AgentEntry");
assert_eq!(agent, back);
}
#[test]
fn flow_entry_serde_round_trip() {
let flow = FlowEntry {
id: "f1".into(),
name: "Data Pipeline".into(),
node_count: 5,
last_run: Some("2025-06-01T12:00:00Z".into()),
};
let json = serde_json::to_string(&flow).expect("serialize FlowEntry");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize FlowEntry");
assert_eq!(flow, back);
}
#[test]
fn flow_entry_with_none_last_run() {
let flow = FlowEntry {
id: "f2".into(),
name: "New Flow".into(),
node_count: 0,
last_run: None,
};
let json = serde_json::to_string(&flow).expect("serialize");
let back: FlowEntry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(flow, back);
assert_eq!(back.last_run, None);
}
#[test]
fn analytics_metric_negative_change_pct() {
let metric = AnalyticsMetric {
label: "Latency".into(),
value: "120ms".into(),
change_pct: -15.5,
};
let json = serde_json::to_string(&metric).expect("serialize AnalyticsMetric");
let back: AnalyticsMetric =
serde_json::from_str(&json).expect("deserialize AnalyticsMetric");
assert_eq!(metric, back);
assert!(back.change_pct < 0.0);
}
}

View File

@@ -23,3 +23,61 @@ pub struct NewsCard {
pub thumbnail_url: Option<String>,
pub published_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn news_card_serde_round_trip() {
let card = NewsCard {
title: "AI Breakthrough".into(),
source: "techcrunch.com".into(),
summary: "New model released".into(),
content: "Full article content here".into(),
category: "AI".into(),
url: "https://example.com/article".into(),
thumbnail_url: Some("https://example.com/thumb.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize NewsCard");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize NewsCard");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_none() {
let card = NewsCard {
title: "No Thumb".into(),
source: "bbc.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "Tech".into(),
url: "https://bbc.com/article".into(),
thumbnail_url: None,
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card, back);
}
#[test]
fn news_card_thumbnail_some() {
let card = NewsCard {
title: "With Thumb".into(),
source: "cnn.com".into(),
summary: "Summary".into(),
content: "Content".into(),
category: "News".into(),
url: "https://cnn.com/article".into(),
thumbnail_url: Some("https://cnn.com/img.jpg".into()),
published_at: "2025-06-01".into(),
};
let json = serde_json::to_string(&card).expect("serialize");
assert!(json.contains("img.jpg"));
let back: NewsCard = serde_json::from_str(&json).expect("deserialize");
assert_eq!(card.thumbnail_url, back.thumbnail_url);
}
}

View File

@@ -116,3 +116,122 @@ pub struct OrgBillingRecord {
/// Number of tokens consumed during this cycle.
pub tokens_used: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn member_role_label_admin() {
assert_eq!(MemberRole::Admin.label(), "Admin");
}
#[test]
fn member_role_label_member() {
assert_eq!(MemberRole::Member.label(), "Member");
}
#[test]
fn member_role_label_viewer() {
assert_eq!(MemberRole::Viewer.label(), "Viewer");
}
#[test]
fn member_role_all_returns_three_in_order() {
let all = MemberRole::all();
assert_eq!(all.len(), 3);
assert_eq!(all[0], MemberRole::Admin);
assert_eq!(all[1], MemberRole::Member);
assert_eq!(all[2], MemberRole::Viewer);
}
#[test]
fn member_role_serde_round_trip() {
for role in MemberRole::all() {
let json =
serde_json::to_string(role).unwrap_or_else(|_| panic!("serialize {:?}", role));
let back: MemberRole =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", role));
assert_eq!(*role, back);
}
}
#[test]
fn org_member_serde_round_trip() {
let member = OrgMember {
id: "m1".into(),
name: "Alice".into(),
email: "alice@example.com".into(),
role: MemberRole::Admin,
joined_at: "2025-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&member).expect("serialize OrgMember");
let back: OrgMember = serde_json::from_str(&json).expect("deserialize OrgMember");
assert_eq!(member, back);
}
#[test]
fn pricing_plan_with_max_seats() {
let plan = PricingPlan {
id: "team".into(),
name: "Team".into(),
price_eur: 49,
features: vec!["SSO".into(), "Priority".into()],
highlighted: true,
max_seats: Some(25),
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
}
#[test]
fn pricing_plan_without_max_seats() {
let plan = PricingPlan {
id: "enterprise".into(),
name: "Enterprise".into(),
price_eur: 199,
features: vec!["Unlimited".into()],
highlighted: false,
max_seats: None,
};
let json = serde_json::to_string(&plan).expect("serialize PricingPlan");
let back: PricingPlan = serde_json::from_str(&json).expect("deserialize PricingPlan");
assert_eq!(plan, back);
assert!(json.contains("null") || !json.contains("max_seats"));
}
#[test]
fn billing_usage_serde_round_trip() {
let usage = BillingUsage {
seats_used: 5,
seats_total: 10,
tokens_used: 1_000_000,
tokens_limit: 5_000_000,
billing_cycle_end: "2025-12-31".into(),
};
let json = serde_json::to_string(&usage).expect("serialize BillingUsage");
let back: BillingUsage = serde_json::from_str(&json).expect("deserialize BillingUsage");
assert_eq!(usage, back);
}
#[test]
fn org_settings_default() {
let settings = OrgSettings::default();
assert_eq!(settings.org_id, "");
assert_eq!(settings.plan_id, "");
assert!(settings.enabled_features.is_empty());
assert_eq!(settings.stripe_customer_id, "");
}
#[test]
fn org_billing_record_default() {
let record = OrgBillingRecord::default();
assert_eq!(record.org_id, "");
assert_eq!(record.cycle_start, "");
assert_eq!(record.cycle_end, "");
assert_eq!(record.seats_used, 0);
assert_eq!(record.tokens_used, 0);
}
}

View File

@@ -72,3 +72,84 @@ pub struct ProviderConfig {
pub selected_embedding: String,
pub api_key_set: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn llm_provider_label_ollama() {
assert_eq!(LlmProvider::Ollama.label(), "Ollama");
}
#[test]
fn llm_provider_label_hugging_face() {
assert_eq!(LlmProvider::HuggingFace.label(), "Hugging Face");
}
#[test]
fn llm_provider_label_openai() {
assert_eq!(LlmProvider::OpenAi.label(), "OpenAI");
}
#[test]
fn llm_provider_label_anthropic() {
assert_eq!(LlmProvider::Anthropic.label(), "Anthropic");
}
#[test]
fn llm_provider_serde_round_trip() {
for variant in [
LlmProvider::Ollama,
LlmProvider::HuggingFace,
LlmProvider::OpenAi,
LlmProvider::Anthropic,
] {
let json = serde_json::to_string(&variant)
.unwrap_or_else(|_| panic!("serialize {:?}", variant));
let back: LlmProvider =
serde_json::from_str(&json).unwrap_or_else(|_| panic!("deserialize {:?}", variant));
assert_eq!(variant, back);
}
}
#[test]
fn model_entry_serde_round_trip() {
let entry = ModelEntry {
id: "llama3.1:8b".into(),
name: "Llama 3.1 8B".into(),
provider: LlmProvider::Ollama,
context_window: 8192,
};
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");
assert_eq!(entry, back);
}
#[test]
fn embedding_entry_serde_round_trip() {
let entry = EmbeddingEntry {
id: "nomic-embed".into(),
name: "Nomic Embed".into(),
provider: LlmProvider::HuggingFace,
dimensions: 768,
};
let json = serde_json::to_string(&entry).expect("serialize EmbeddingEntry");
let back: EmbeddingEntry = serde_json::from_str(&json).expect("deserialize EmbeddingEntry");
assert_eq!(entry, back);
}
#[test]
fn provider_config_serde_round_trip() {
let cfg = ProviderConfig {
provider: LlmProvider::Anthropic,
selected_model: "claude-3".into(),
selected_embedding: "embed-v1".into(),
api_key_set: true,
};
let json = serde_json::to_string(&cfg).expect("serialize ProviderConfig");
let back: ProviderConfig = serde_json::from_str(&json).expect("deserialize ProviderConfig");
assert_eq!(cfg, back);
}
}

View File

@@ -70,3 +70,81 @@ pub struct UserPreferences {
#[serde(default)]
pub provider_config: UserProviderConfig,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn user_data_default() {
let ud = UserData::default();
assert_eq!(ud.name, "");
}
#[test]
fn auth_info_default_not_authenticated() {
let info = AuthInfo::default();
assert!(!info.authenticated);
assert_eq!(info.sub, "");
assert_eq!(info.email, "");
assert_eq!(info.name, "");
assert_eq!(info.avatar_url, "");
assert_eq!(info.librechat_url, "");
}
#[test]
fn auth_info_serde_round_trip() {
let info = AuthInfo {
authenticated: true,
sub: "sub-123".into(),
email: "test@example.com".into(),
name: "Test User".into(),
avatar_url: "https://example.com/avatar.png".into(),
librechat_url: "https://chat.example.com".into(),
};
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
assert_eq!(info, back);
}
#[test]
fn user_preferences_default() {
let prefs = UserPreferences::default();
assert_eq!(prefs.sub, "");
assert_eq!(prefs.org_id, "");
assert!(prefs.custom_topics.is_empty());
assert!(prefs.recent_searches.is_empty());
}
#[test]
fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig {
default_provider: "ollama".into(),
default_model: "llama3.1:8b".into(),
openai_api_key: None,
anthropic_api_key: None,
huggingface_api_key: None,
ollama_url_override: String::new(),
};
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key"));
assert!(!json.contains("anthropic_api_key"));
assert!(!json.contains("huggingface_api_key"));
}
#[test]
fn user_provider_config_serde_round_trip_with_keys() {
let cfg = UserProviderConfig {
default_provider: "openai".into(),
default_model: "gpt-4o".into(),
openai_api_key: Some("sk-test".into()),
anthropic_api_key: Some("ak-test".into()),
huggingface_api_key: None,
ollama_url_override: "http://custom:11434".into(),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(cfg, back);
}
}