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 <noreply@anthropic.com>
This commit is contained in:
@@ -440,7 +440,12 @@ pub async fn chat_complete(
|
|||||||
let session = doc_to_chat_session(&session_doc);
|
let session = doc_to_chat_session(&session_doc);
|
||||||
|
|
||||||
// Resolve provider URL and model
|
// 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
|
// Parse messages from JSON
|
||||||
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_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"))
|
.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")]
|
#[cfg(feature = "server")]
|
||||||
fn resolve_provider_url(
|
pub(crate) fn resolve_provider_url(
|
||||||
state: &crate::infrastructure::ServerState,
|
ollama_url: &str,
|
||||||
|
ollama_model: &str,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
) -> (String, String) {
|
) -> (String, String) {
|
||||||
@@ -496,12 +513,229 @@ fn resolve_provider_url(
|
|||||||
),
|
),
|
||||||
// Default to Ollama
|
// Default to Ollama
|
||||||
_ => (
|
_ => (
|
||||||
state.services.ollama_url.clone(),
|
ollama_url.to_string(),
|
||||||
if model.is_empty() {
|
if model.is_empty() {
|
||||||
state.services.ollama_model.clone()
|
ollama_model.to_string()
|
||||||
} else {
|
} else {
|
||||||
model.to_string()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,7 +72,25 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let html = resp.text().await.ok()?;
|
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.
|
// Strategy 1: Extract from semantic article containers.
|
||||||
// Most news sites wrap the main content in <article>, <main>,
|
// 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.
|
/// 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()
|
parts.iter().map(|s| s.len()).sum()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,3 +343,150 @@ pub async fn chat_followup(
|
|||||||
.map(|choice| choice.message.content.clone())
|
.map(|choice| choice.message.content.clone())
|
||||||
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,91 @@ pub struct User {
|
|||||||
/// Avatar / profile picture URL.
|
/// Avatar / profile picture URL.
|
||||||
pub avatar_url: String,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user