use crate::classify::tiers::llm::{
LlmClassifier, LlmVerdict, ANTHROPIC_API_VERSION, ANTHROPIC_DEFAULT_MODEL, ANTHROPIC_ENDPOINT,
SYSTEM_PROMPT,
};
use crate::core::config::LlmSource;
use crate::core::models::ClassificationMethod;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn has_api_key_reflects_key_state() {
let with_key = LlmClassifier::new("gpt-4o-mini", Some("sk-test".to_string()));
assert!(with_key.has_api_key());
let without_key = LlmClassifier::new("gpt-4o-mini", None);
assert!(!without_key.has_api_key());
}
#[test]
fn build_anthropic_sets_format_flag_and_version_header() {
let llm = LlmClassifier::build_anthropic(
"claude-3-5-haiku-latest",
Some("sk-ant-test".to_string()), );
assert!(llm.use_anthropic_format, "must set use_anthropic_format");
assert!(llm.api_key.is_some(), "api_key must be set");
assert_eq!(llm.endpoint, ANTHROPIC_ENDPOINT);
assert!(
llm.extra_headers.contains_key("anthropic-version"),
"anthropic-version header must be set"
);
let ver = llm.extra_headers.get("anthropic-version").unwrap();
assert_eq!(ver, ANTHROPIC_API_VERSION);
}
#[test]
fn build_anthropic_without_key_has_no_api_key() {
let llm = LlmClassifier::build_anthropic("claude-3-5-haiku-latest", None);
assert!(!llm.has_api_key());
}
#[tokio::test]
async fn anthropic_response_parsing() {
let server = MockServer::start().await;
let body = serde_json::json!({
"content": [
{
"type": "text",
"text": "{\"category\":\"bugfix\",\"subcategory\":\"null-check\",\"confidence\":0.92,\"complexity\":2}"
}
]
});
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let llm = LlmClassifier::build_anthropic(
"claude-3-5-haiku-latest",
Some("sk-ant-test".to_string()), )
.with_endpoint(format!("{}/v1/messages", server.uri()));
let r = llm
.classify("fix: handle null in user endpoint")
.await
.expect("verdict");
assert_eq!(r.category, "bugfix");
assert_eq!(r.subcategory.as_deref(), Some("null-check"));
assert!((r.confidence - 0.92).abs() < 1e-6);
assert_eq!(r.complexity, Some(2));
assert_eq!(r.method, ClassificationMethod::LlmFallback);
}
#[tokio::test]
async fn anthropic_api_request_sets_correct_headers() {
let server = MockServer::start().await;
let body = serde_json::json!({
"content": [
{
"type": "text",
"text": "{\"category\":\"chore\",\"subcategory\":null,\"confidence\":0.8,\"complexity\":1}"
}
]
});
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("x-api-key", "sk-ant-test")) .and(header("anthropic-version", ANTHROPIC_API_VERSION))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let llm = LlmClassifier::build_anthropic(
"claude-3-5-haiku-latest",
Some("sk-ant-test".to_string()), )
.with_endpoint(format!("{}/v1/messages", server.uri()));
let r = llm
.classify("chore: bump version")
.await
.expect("verdict with correct headers");
assert_eq!(r.category, "chore");
}
#[tokio::test]
async fn from_llm_config_anthropic_api_reads_api_key_env() {
let var_name = "TGA_TEST_ANTHROPIC_KEY_9f2e";
std::env::set_var(var_name, "sk-ant-from-env");
let cfg = crate::core::config::LlmConfig {
source: LlmSource::AnthropicApi,
api_key_env: var_name.to_string(),
region: None,
model: Some("claude-3-5-haiku-latest".to_string()),
};
let result = LlmClassifier::from_llm_config(&cfg, "claude-3-5-haiku-latest").await;
std::env::remove_var(var_name);
let llm = result.expect("should build from env var");
assert!(llm.has_api_key());
assert!(llm.use_anthropic_format);
}
#[tokio::test]
async fn from_llm_config_anthropic_api_missing_key_errors() {
let var_name = "TGA_TEST_ANTHROPIC_MISSING_KEY_7c4b";
std::env::remove_var(var_name);
let cfg = crate::core::config::LlmConfig {
source: LlmSource::AnthropicApi,
api_key_env: var_name.to_string(),
region: None,
model: None,
};
let result = LlmClassifier::from_llm_config(&cfg, "gpt-4o-mini").await;
assert!(result.is_err(), "missing env var must produce Err");
let err = result.err().expect("just asserted is_err");
assert!(
err.contains(var_name),
"error must name the missing var: {err}"
);
}
#[tokio::test]
async fn anthropic_default_model_used_when_none_configured() {
let var_name = "TGA_TEST_ANTHROPIC_DEFAULT_MODEL_3a8d";
std::env::set_var(var_name, "sk-ant-test-model");
let cfg = crate::core::config::LlmConfig {
source: LlmSource::AnthropicApi,
api_key_env: var_name.to_string(),
region: None,
model: None, };
let result = LlmClassifier::from_llm_config(&cfg, "gpt-4o-mini").await;
std::env::remove_var(var_name);
let llm = result.expect("build from env");
assert_eq!(
llm.model, ANTHROPIC_DEFAULT_MODEL,
"must substitute ANTHROPIC_DEFAULT_MODEL, not gpt-4o-mini"
);
}
#[test]
fn llm_section_presence_self_enables_tier() {
let cfg = crate::core::config::Config {
llm: Some(crate::core::config::LlmConfig {
source: LlmSource::AnthropicApi,
api_key_env: "ANTHROPIC_ANALYTICS_API_KEY".to_string(), region: None,
model: Some("claude-3-5-haiku-latest".to_string()),
}),
classification: None,
..crate::core::config::Config::default()
};
let use_llm = cfg.llm.is_some()
|| cfg
.classification
.as_ref()
.map(|c| c.use_llm)
.unwrap_or(false);
assert!(
use_llm,
"llm: section presence must self-enable the LLM tier"
);
}
#[tokio::test]
async fn classify_returns_none_without_api_key() {
let llm = LlmClassifier::new("gpt-4o-mini", None);
assert!(llm.classify("feat: anything").await.is_none());
}
#[tokio::test]
async fn classify_does_not_set_ticket_id() {
let server = MockServer::start().await;
let body = serde_json::json!({
"choices": [{
"message": {
"content": "{\"category\": \"bugfix\", \
\"subcategory\": null, \
\"confidence\": 0.8}"
}
}]
});
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let llm = LlmClassifier::new("gpt-4o-mini", Some("sk-test".to_string()))
.with_endpoint(format!("{}/v1/chat/completions", server.uri()));
let r = llm
.classify("fix: handle null in PROJ-1234 endpoint")
.await
.expect("LLM verdict");
assert_eq!(r.ticket_id, None);
}
#[test]
fn llm_verdict_deserializes_complexity() {
let with: LlmVerdict =
serde_json::from_str(r#"{"category":"feature","confidence":0.9,"complexity":3}"#)
.expect("deserialize verdict with complexity");
assert_eq!(with.complexity, Some(3));
let without: LlmVerdict = serde_json::from_str(r#"{"category":"feature","confidence":0.9}"#)
.expect("deserialize verdict without complexity");
assert_eq!(without.complexity, None);
}
#[test]
fn system_prompt_requests_complexity() {
assert!(
SYSTEM_PROMPT.contains("complexity"),
"system prompt must instruct the model to return a complexity score"
);
}
#[tokio::test]
async fn classify_dispatches_to_endpoint_when_keyed() {
let server = MockServer::start().await;
let body = serde_json::json!({
"choices": [{
"message": {
"content": "{\"category\": \"feature\", \
\"subcategory\": \"new-auth\", \
\"confidence\": 0.91}"
}
}]
});
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let llm = LlmClassifier::new("gpt-4o-mini", Some("sk-test".to_string()))
.with_endpoint(format!("{}/v1/chat/completions", server.uri()));
let r = llm.classify("chore: bump deps").await.expect("LLM verdict");
assert_eq!(r.category, "feature");
assert_eq!(r.subcategory.as_deref(), Some("new-auth"));
assert!((r.confidence - 0.91).abs() < 1e-6);
assert_eq!(r.method, ClassificationMethod::LlmFallback);
}