From 1f7748c5b42fb5834251120054f6d1e04826681d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 23 Feb 2026 21:51:46 +0100 Subject: [PATCH 1/6] fix(librechat): remove prompt=none for local dev compatibility prompt=none causes silent failure when no Keycloak session exists yet. Standard OIDC flow still provides seamless SSO when the user has an active Keycloak session from the dashboard. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1d8b2ef..d13949e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,7 +70,6 @@ services: OPENID_CALLBACK_URL: /oauth/openid/callback OPENID_SCOPE: openid profile email OPENID_BUTTON_LABEL: Login with CERTifAI - OPENID_AUTH_EXTRA_PARAMS: prompt=none # Disable local auth (SSO only) ALLOW_EMAIL_LOGIN: "false" ALLOW_REGISTRATION: "false" -- 2.49.1 From e24471164454faf05cc8ddfea6d6ca403c8bd4e3 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 23 Feb 2026 22:27:07 +0100 Subject: [PATCH 2/6] feat(librechat): add OIDC HTTP patch and prompt=none for seamless SSO Switch to host networking so LibreChat can reach Keycloak on localhost. Patch openidStrategy.js to allow HTTP OIDC issuers for local dev (openid-client v6 enforces HTTPS by default). Add support for OPENID_AUTH_EXTRA_PARAMS env var and set prompt=none for automatic SSO login when a Keycloak session exists. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index d13949e..1d8b2ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,7 @@ services: OPENID_CALLBACK_URL: /oauth/openid/callback OPENID_SCOPE: openid profile email OPENID_BUTTON_LABEL: Login with CERTifAI + OPENID_AUTH_EXTRA_PARAMS: prompt=none # Disable local auth (SSO only) ALLOW_EMAIL_LOGIN: "false" ALLOW_REGISTRATION: "false" -- 2.49.1 From 6890ed9b42a7762c11961a8bb12c580e6ce4629d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 24 Feb 2026 18:43:27 +0100 Subject: [PATCH 3/6] test: add comprehensive unit test suite (~85 new tests) Add unit tests across all model and server infrastructure layers, increasing test count from 7 to 92. Covers serde round-trips, enum methods, defaults, config parsing, error mapping, PKCE crypto (with RFC 7636 test vector), OAuth store, and SearXNG ranking/dedup logic. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 65 +++++++++++++ Cargo.toml | 4 + src/infrastructure/auth.rs | 131 +++++++++++++++++++++++-- src/infrastructure/config.rs | 157 ++++++++++++++++++++++++++++++ src/infrastructure/error.rs | 50 ++++++++++ src/infrastructure/searxng.rs | 173 +++++++++++++++++++++++++++++++++- src/models/chat.rs | 160 +++++++++++++++++++++++++++++++ src/models/developer.rs | 60 ++++++++++++ src/models/news.rs | 58 ++++++++++++ src/models/organization.rs | 119 +++++++++++++++++++++++ src/models/provider.rs | 81 ++++++++++++++++ src/models/user.rs | 78 +++++++++++++++ 12 files changed, 1123 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57bf95e..9e9ff42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "maud", "mongodb", "petname", + "pretty_assertions", "pulldown-cmark", "rand 0.10.0", "reqwest 0.13.2", @@ -783,6 +784,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "serial_test", "sha2", "thiserror 2.0.18", "time", @@ -882,6 +884,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -3246,6 +3254,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3823,6 +3841,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3862,6 +3889,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "secrecy" version = "0.10.3" @@ -4082,6 +4115,32 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5683,6 +5742,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yazi" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8caa25c..9de0a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,10 @@ server = [ "dep:bytes", ] +[dev-dependencies] +pretty_assertions = "1.4" +serial_test = "3.2" + [[bin]] name = "dashboard" path = "bin/main.rs" diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index 9894878..8c46836 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -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, - code_verifier: String, +pub(crate) struct PendingOAuthEntry { + pub(crate) redirect_url: Option, + 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>>); 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 { + pub(crate) fn take(&self, state: &str) -> Option { #[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()); + } +} diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index c068aa7..3ce3ac5 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -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__")); + } +} diff --git a/src/infrastructure/error.rs b/src/infrastructure/error.rs index 65b2d51..838d20a 100644 --- a/src/infrastructure/error.rs +++ b/src/infrastructure/error.rs @@ -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); + } +} diff --git a/src/infrastructure/searxng.rs b/src/infrastructure/searxng.rs index 713e67e..4e808e4 100644 --- a/src/infrastructure/searxng.rs +++ b/src/infrastructure/searxng.rs @@ -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, @@ -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, } @@ -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, max_results: usize, ) -> Vec { @@ -285,3 +285,166 @@ pub async fn get_trending_topics() -> Result, 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()); + } +} diff --git a/src/models/chat.rs b/src/models/chat.rs index e6f6134..aa869de 100644 --- a/src/models/chat.rs +++ b/src/models/chat.rs @@ -105,3 +105,163 @@ pub struct ChatMessage { pub attachments: Vec, 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"); + } +} diff --git a/src/models/developer.rs b/src/models/developer.rs index 1138e96..9ba530d 100644 --- a/src/models/developer.rs +++ b/src/models/developer.rs @@ -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); + } +} diff --git a/src/models/news.rs b/src/models/news.rs index 833920a..ffa3930 100644 --- a/src/models/news.rs +++ b/src/models/news.rs @@ -23,3 +23,61 @@ pub struct NewsCard { pub thumbnail_url: Option, 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); + } +} diff --git a/src/models/organization.rs b/src/models/organization.rs index 790e687..0c6745d 100644 --- a/src/models/organization.rs +++ b/src/models/organization.rs @@ -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); + } +} diff --git a/src/models/provider.rs b/src/models/provider.rs index a08a637..48ee498 100644 --- a/src/models/provider.rs +++ b/src/models/provider.rs @@ -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); + } +} diff --git a/src/models/user.rs b/src/models/user.rs index a3367bd..5bbc8f9 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -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); + } +} -- 2.49.1 From d4274387a948381f7d46fa0ea10ca795d4245e7a Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 09:39:09 +0100 Subject: [PATCH 4/6] test: add infrastructure logic unit tests (37 new tests) Add Phase 2 test coverage for infrastructure modules: - state.rs: 6 tests (defaults, serde round-trips, UserState deref/clone) - provider_client.rs: 2 tests (ProviderMessage serde) - llm.rs: 12 tests (FollowUpMessage serde, joined_len, parse_article_html extraction with article/main/role=main tags, fallback, exclusions, truncation, fragment skipping) - chat.rs: 17 tests (doc_to_chat_session, doc_to_chat_message BSON conversion, resolve_provider_url for all providers) Refactor: extract parse_article_html from fetch_article_text for testability without HTTP. Refactor resolve_provider_url to accept explicit params instead of full ServerState, avoiding need for MongoDB in tests. Total test count: 129 (up from 92). Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/chat.rs | 246 +++++++++++++++++++++++++- src/infrastructure/llm.rs | 169 +++++++++++++++++- src/infrastructure/provider_client.rs | 27 +++ src/infrastructure/state.rs | 88 +++++++++ 4 files changed, 522 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/chat.rs b/src/infrastructure/chat.rs index 03c3015..5b5e99a 100644 --- a/src/infrastructure/chat.rs +++ b/src/infrastructure/chat.rs @@ -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::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); + } + } +} diff --git a/src/infrastructure/llm.rs b/src/infrastructure/llm.rs index 07379c0..b68e2ab 100644 --- a/src/infrastructure/llm.rs +++ b/src/infrastructure/llm.rs @@ -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 `
`, `
`, or `[role="main"]` + /// 2. Fall back to all `

` 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 { + let document = scraper::Html::parse_document(html); // Strategy 1: Extract from semantic article containers. // Most news sites wrap the main content in

,
, @@ -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!("

{body}

"); + 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!("

{body}

"); + 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
/
, so falls back to

tags + let body = lorem(250); + let html = format!("

{body}

"); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected fallback to

tags"); + } + + #[test] + fn excludes_nav_footer_aside_content() { + // Content only inside excluded containers -- should be excluded + let body = lorem(250); + let html = format!( + "\ +

\ +

{body}

\ + \ + " + ); + 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 = "

Short.

"; + 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!("

{body}

"); + 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 = "
\ +

Short frag one

\ +

Another tiny bit

\ +
"; + 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!( + "\ +

{body}

\ + " + ); + let result = parse_article_html(&html); + assert!(result.is_some(), "expected Some for role=main"); + } + } +} diff --git a/src/infrastructure/provider_client.rs b/src/infrastructure/provider_client.rs index ce915b1..804eba6 100644 --- a/src/infrastructure/provider_client.rs +++ b/src/infrastructure/provider_client.rs @@ -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?"); + } +} diff --git a/src/infrastructure/state.rs b/src/infrastructure/state.rs index d6c2bc1..9d3a75e 100644 --- a/src/infrastructure/state.rs +++ b/src/infrastructure/state.rs @@ -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); + } +} -- 2.49.1 From 0e3e1a707f4b394dbc7cd6653dadfd8834e6f2a8 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 10:04:09 +0100 Subject: [PATCH 5/6] test: add Playwright E2E test suite (30 tests) Add browser-level end-to-end tests covering public pages, Keycloak OAuth authentication flow, dashboard interactions, providers config, developer section, organization pages, and sidebar navigation. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 +++ bun.lock | 9 +++++ e2e/auth.setup.ts | 24 +++++++++++++ e2e/auth.spec.ts | 72 ++++++++++++++++++++++++++++++++++++++ e2e/dashboard.spec.ts | 75 ++++++++++++++++++++++++++++++++++++++++ e2e/developer.spec.ts | 33 ++++++++++++++++++ e2e/navigation.spec.ts | 52 ++++++++++++++++++++++++++++ e2e/organization.spec.ts | 41 ++++++++++++++++++++++ e2e/providers.spec.ts | 55 +++++++++++++++++++++++++++++ e2e/public.spec.ts | 60 ++++++++++++++++++++++++++++++++ package.json | 1 + playwright.config.ts | 40 +++++++++++++++++++++ 12 files changed, 467 insertions(+) create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/dashboard.spec.ts create mode 100644 e2e/developer.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 e2e/organization.spec.ts create mode 100644 e2e/providers.spec.ts create mode 100644 e2e/public.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 75620fe..8e10faa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ keycloak/* node_modules/ searxng/ + +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ diff --git a/bun.lock b/bun.lock index 5d6c3e9..5849f07 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "tailwindcss": "^4.1.18", }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest", }, "peerDependencies": { @@ -16,6 +17,8 @@ }, }, "packages": { + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -24,6 +27,12 @@ "daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..e8b6cb1 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,24 @@ +import { test as setup, expect } from "@playwright/test"; + +const AUTH_FILE = "e2e/.auth/user.json"; + +setup("authenticate via Keycloak", async ({ page }) => { + // Navigate to a protected route to trigger the auth redirect chain: + // /dashboard -> /auth (Axum) -> Keycloak login page + await page.goto("/dashboard"); + + // Wait for Keycloak login form to appear + await page.waitForSelector("#username", { timeout: 15_000 }); + + // Fill Keycloak credentials + await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local"); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + // Wait for redirect back to the app dashboard + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar")).toBeVisible(); + + // Persist authenticated state (cookies + localStorage) + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..fe35ce6 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test"; + +// These tests use a fresh browser context (no saved auth state) +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("Authentication flow", () => { + test("unauthenticated visit to /dashboard redirects to Keycloak", async ({ + page, + }) => { + await page.goto("/dashboard"); + + // Should end up on Keycloak login page + await page.waitForSelector("#username", { timeout: 15_000 }); + await expect(page.locator("#kc-login")).toBeVisible(); + }); + + test("valid credentials log in and redirect to dashboard", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".dashboard-page")).toBeVisible(); + }); + + test("dashboard shows sidebar with user info after login", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar-name")).toBeVisible(); + await expect(page.locator(".sidebar-email")).toBeVisible(); + }); + + test("logout redirects away from dashboard", async ({ page }) => { + // First log in + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + + // Click logout + await page.locator('a.logout-btn, a[href="/logout"]').click(); + + // Should no longer be on the dashboard + await expect(page).not.toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..89094e2 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Dashboard", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + // Wait for WASM hydration and auth check to complete + await page.waitForSelector(".dashboard-page", { timeout: 15_000 }); + }); + + test("dashboard page loads with page header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Dashboard"); + }); + + test("default topic chips are visible", async ({ page }) => { + const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"]; + + for (const topic of topics) { + await expect( + page.locator(".filter-tab", { hasText: topic }) + ).toBeVisible(); + } + }); + + test("clicking a topic chip triggers search", async ({ page }) => { + const chip = page.locator(".filter-tab", { hasText: "AI" }); + await chip.click(); + + // Either a loading state or results should appear + const searchingOrResults = page + .locator(".dashboard-loading, .news-grid, .dashboard-empty"); + await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("news cards render after search completes", async ({ page }) => { + // Click a topic to trigger search + await page.locator(".filter-tab", { hasText: "Technology" }).click(); + + // Wait for loading to finish + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => { + // Loading may already be done + }); + + // Either news cards or an empty state message should be visible + const content = page.locator(".news-grid .news-card, .dashboard-empty"); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("clicking a news card opens article detail panel", async ({ page }) => { + // Trigger a search and wait for results + await page.locator(".filter-tab", { hasText: "AI" }).click(); + + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => {}); + + const firstCard = page.locator(".news-card").first(); + // Only test if cards are present (search results depend on live data) + if (await firstCard.isVisible().catch(() => false)) { + await firstCard.click(); + await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible(); + } + }); + + test("settings toggle opens settings panel", async ({ page }) => { + const settingsBtn = page.locator(".settings-toggle"); + await settingsBtn.click(); + + await expect(page.locator(".settings-panel")).toBeVisible(); + await expect(page.locator(".settings-panel-title")).toBeVisible(); + }); +}); diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts new file mode 100644 index 0000000..9d84e30 --- /dev/null +++ b/e2e/developer.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Developer section", () => { + test("agents page loads with sub-nav tabs", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".developer-shell", { timeout: 15_000 }); + + const nav = page.locator(".sub-nav"); + await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); + }); + + test("agents page shows Coming Soon badge", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + await expect(page.locator("h2")).toContainText("Agent Builder"); + }); + + test("analytics page loads via sub-nav", async ({ page }) => { + await page.goto("/developer/analytics"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator("h2")).toContainText("Analytics"); + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..cf3859e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Sidebar navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector(".sidebar", { timeout: 15_000 }); + }); + + test("sidebar links route to correct pages", async ({ page }) => { + const navTests = [ + { label: "Providers", url: /\/providers/ }, + { label: "Developer", url: /\/developer\/agents/ }, + { label: "Organization", url: /\/organization\/pricing/ }, + { label: "Dashboard", url: /\/dashboard/ }, + ]; + + for (const { label, url } of navTests) { + await page.locator(".sidebar-link", { hasText: label }).click(); + await expect(page).toHaveURL(url, { timeout: 10_000 }); + } + }); + + test("browser back/forward navigation works", async ({ page }) => { + // Navigate to Providers + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Navigate to Developer + await page.locator(".sidebar-link", { hasText: "Developer" }).click(); + await expect(page).toHaveURL(/\/developer/); + + // Go back + await page.goBack(); + await expect(page).toHaveURL(/\/providers/); + + // Go forward + await page.goForward(); + await expect(page).toHaveURL(/\/developer/); + }); + + test("logo link navigates to dashboard", async ({ page }) => { + // Navigate away first + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Click the logo/brand in sidebar header + const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first(); + await logo.click(); + + await expect(page).toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts new file mode 100644 index 0000000..c786254 --- /dev/null +++ b/e2e/organization.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Organization section", () => { + test("pricing page loads with three pricing cards", async ({ page }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + const cards = page.locator(".pricing-card"); + await expect(cards).toHaveCount(3); + }); + + test("pricing cards show Starter, Team, Enterprise tiers", async ({ + page, + }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible(); + }); + + test("organization dashboard loads with billing stats", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".page-header")).toContainText("Organization"); + await expect(page.locator(".org-stats-bar")).toBeVisible(); + await expect(page.locator(".org-stat").first()).toBeVisible(); + }); + + test("member table is visible on org dashboard", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".org-table")).toBeVisible(); + await expect(page.locator(".org-table thead")).toContainText("Name"); + await expect(page.locator(".org-table thead")).toContainText("Email"); + await expect(page.locator(".org-table thead")).toContainText("Role"); + }); +}); diff --git a/e2e/providers.spec.ts b/e2e/providers.spec.ts new file mode 100644 index 0000000..63ea082 --- /dev/null +++ b/e2e/providers.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Providers page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/providers"); + await page.waitForSelector(".providers-page", { timeout: 15_000 }); + }); + + test("providers page loads with header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Providers"); + }); + + test("provider dropdown has Ollama selected by default", async ({ + page, + }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + await expect(providerSelect).toHaveValue(/ollama/i); + }); + + test("changing provider updates the model dropdown", async ({ page }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + // Get current model options + const modelSelect = page + .locator(".form-group") + .filter({ hasText: /^Model/ }) + .locator("select"); + const initialOptions = await modelSelect.locator("option").allTextContents(); + + // Change to a different provider + await providerSelect.selectOption({ label: "OpenAI" }); + + // Wait for model list to update + await page.waitForTimeout(500); + const updatedOptions = await modelSelect.locator("option").allTextContents(); + + // Model options should differ between providers + expect(updatedOptions).not.toEqual(initialOptions); + }); + + test("save button shows confirmation feedback", async ({ page }) => { + const saveBtn = page.locator("button", { hasText: "Save Configuration" }); + await saveBtn.click(); + + await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".form-success")).toContainText("saved"); + }); +}); diff --git a/e2e/public.spec.ts b/e2e/public.spec.ts new file mode 100644 index 0000000..afd7f3b --- /dev/null +++ b/e2e/public.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Public pages", () => { + test("landing page loads with heading and nav links", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI"); + await expect(page.locator(".landing-nav-links")).toBeVisible(); + await expect(page.locator('a[href="#features"]')).toBeVisible(); + await expect(page.locator('a[href="#how-it-works"]')).toBeVisible(); + await expect(page.locator('a[href="#pricing"]')).toBeVisible(); + }); + + test("landing page Log In link navigates to login route", async ({ + page, + }) => { + await page.goto("/"); + + const loginLink = page + .locator(".landing-nav-actions a, .landing-nav-actions Link") + .filter({ hasText: "Log In" }); + await loginLink.click(); + + await expect(page).toHaveURL(/\/login/); + }); + + test("impressum page loads with legal content", async ({ page }) => { + await page.goto("/impressum"); + + await expect(page.locator("h1")).toHaveText("Impressum"); + await expect( + page.locator("h2", { hasText: "Information according to" }) + ).toBeVisible(); + await expect(page.locator(".legal-content")).toContainText( + "CERTifAI GmbH" + ); + }); + + test("privacy page loads with privacy content", async ({ page }) => { + await page.goto("/privacy"); + + await expect(page.locator("h1")).toHaveText("Privacy Policy"); + await expect( + page.locator("h2", { hasText: "Introduction" }) + ).toBeVisible(); + await expect( + page.locator("h2", { hasText: "Your Rights" }) + ).toBeVisible(); + }); + + test("footer links are present on landing page", async ({ page }) => { + await page.goto("/"); + + const footer = page.locator(".landing-footer"); + await expect(footer.locator('a:has-text("Impressum")')).toBeVisible(); + await expect( + footer.locator('a:has-text("Privacy Policy")') + ).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 0072209..8ad4cbb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest" }, "peerDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d58df60 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html"], ["list"]], + timeout: 30_000, + + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:8000", + actionTimeout: 10_000, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + name: "public", + testMatch: /public\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "authenticated", + testMatch: /\.spec\.ts$/, + testIgnore: /public\.spec\.ts$/, + dependencies: ["setup"], + use: { + ...devices["Desktop Chrome"], + storageState: "e2e/.auth/user.json", + }, + }, + ], +}); -- 2.49.1 From 2efec74eca6c9d24e34c4bbec584cb4946975788 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 10:08:32 +0100 Subject: [PATCH 6/6] ci: add E2E test job with Playwright and service containers Run Playwright browser tests on main and PRs to main after quality checks pass. Spins up MongoDB and SearXNG as services, starts Keycloak manually post-checkout (needs realm-export.json from repo), builds and serves the Dioxus app, then runs the full E2E suite. Deploy now gates on both unit and E2E tests. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 132 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6312edd..2f47959 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -120,13 +120,143 @@ jobs: run: sccache --show-stats if: always() + # --------------------------------------------------------------------------- + # Stage 2b: E2E tests (only on main / PRs to main, after quality checks) + # --------------------------------------------------------------------------- + e2e: + name: E2E Tests + runs-on: docker + needs: [fmt, clippy, audit] + if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request' + container: + image: rust:1.89-bookworm + # MongoDB and SearXNG can start immediately (no repo files needed). + # Keycloak requires realm-export.json from the repo, so it is started + # manually after checkout via docker CLI. + services: + mongo: + image: mongo:latest + env: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - 27017:27017 + searxng: + image: searxng/searxng:latest + env: + SEARXNG_BASE_URL: http://localhost:8888 + ports: + - 8888:8080 + env: + KEYCLOAK_URL: http://localhost:8080 + KEYCLOAK_REALM: certifai + KEYCLOAK_CLIENT_ID: certifai-dashboard + MONGODB_URI: mongodb://root:example@mongo:27017 + MONGODB_DATABASE: certifai + SEARXNG_URL: http://searxng:8080 + steps: + - name: Checkout + run: | + git init + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git fetch --depth=1 origin "${GITHUB_SHA}" + git checkout FETCH_HEAD + - name: Install system dependencies + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + unzip curl docker.io \ + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 \ + libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \ + libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \ + libcairo2 libasound2 libatspi2.0-0 libxshmfence1 + - name: Start Keycloak + run: | + docker run -d --name ci-keycloak --network host \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -e KC_DB=dev-mem \ + -e KC_HEALTH_ENABLED=true \ + -v "$PWD/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro" \ + -v "$PWD/keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro" \ + quay.io/keycloak/keycloak:26.0 start-dev --import-realm + + echo "Waiting for Keycloak..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8080/realms/certifai > /dev/null 2>&1; then + echo "Keycloak is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "Keycloak failed to start within 60s" + docker logs ci-keycloak + exit 1 + fi + sleep 2 + done + - name: Install sccache + run: | + curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + chmod +x /usr/local/bin/sccache + - name: Install dioxus-cli + run: cargo install dioxus-cli --locked + - name: Install bun + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >> "$GITHUB_PATH" + - name: Install Playwright + run: | + export PATH="$HOME/.bun/bin:$PATH" + bun install + bunx playwright install chromium + - name: Build app + run: dx build --release + - name: Start app and run E2E tests + run: | + export PATH="$HOME/.bun/bin:$PATH" + # Start the app in the background + dx serve --release --port 8000 & + APP_PID=$! + + # Wait for the app to be ready + echo "Waiting for app to start..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8000 > /dev/null 2>&1; then + echo "App is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "App failed to start within 60s" + exit 1 + fi + sleep 1 + done + + BASE_URL=http://localhost:8000 bunx playwright test --reporter=list + + kill "$APP_PID" 2>/dev/null || true + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + - name: Cleanup Keycloak + if: always() + run: docker rm -f ci-keycloak 2>/dev/null || true + - name: Show sccache stats + run: sccache --show-stats + if: always() + # --------------------------------------------------------------------------- # Stage 3: Deploy (only after tests pass, only on main) # --------------------------------------------------------------------------- deploy: name: Deploy runs-on: docker - needs: [test] + needs: [test, e2e] if: github.ref == 'refs/heads/main' container: image: alpine:latest -- 2.49.1