test: add comprehensive unit tests for query builder, CLI args, and config parsing

This commit is contained in:
2026-01-28 14:29:03 -06:00
parent c445190838
commit 37942378ae
4 changed files with 436 additions and 0 deletions
+184
View File
@@ -276,6 +276,190 @@ fn format_time_parameter(duration: Duration) -> (String, String, String) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_defaults() {
let q = SearchQuery::new();
assert_eq!(q.get_max_results(), 8);
assert!(q.get_subject().is_none());
let params = q.to_params();
assert_eq!(params.get("pageMaxSize").unwrap(), "8");
assert_eq!(params.get("pageOffset").unwrap(), "0");
assert_eq!(params.len(), 2);
}
#[test]
fn test_subject_param() {
let params = SearchQuery::new().subject("CS").to_params();
assert_eq!(params.get("txt_subject").unwrap(), "CS");
}
#[test]
fn test_title_trims_whitespace() {
let params = SearchQuery::new().title(" Intro to CS ").to_params();
assert_eq!(params.get("txt_courseTitle").unwrap(), "Intro to CS");
}
#[test]
fn test_crn_param() {
let params = SearchQuery::new()
.course_reference_number("12345")
.to_params();
assert_eq!(params.get("txt_courseReferenceNumber").unwrap(), "12345");
}
#[test]
fn test_keywords_joined_with_spaces() {
let params = SearchQuery::new()
.keyword("data")
.keyword("science")
.to_params();
assert_eq!(params.get("txt_keywordlike").unwrap(), "data science");
}
#[test]
fn test_keywords_vec() {
let params = SearchQuery::new()
.keywords(vec!["machine".into(), "learning".into()])
.to_params();
assert_eq!(params.get("txt_keywordlike").unwrap(), "machine learning");
}
#[test]
fn test_open_only() {
let params = SearchQuery::new().open_only(true).to_params();
assert_eq!(params.get("chk_open_only").unwrap(), "true");
// open_only(false) still sets the param (it's `.is_some()` check)
let params2 = SearchQuery::new().open_only(false).to_params();
assert_eq!(params2.get("chk_open_only").unwrap(), "true");
}
#[test]
fn test_credits_range() {
let params = SearchQuery::new().credits(3, 6).to_params();
assert_eq!(params.get("txt_credithourlow").unwrap(), "3");
assert_eq!(params.get("txt_credithourhigh").unwrap(), "6");
}
#[test]
fn test_course_number_range() {
let params = SearchQuery::new().course_numbers(3000, 3999).to_params();
assert_eq!(params.get("txt_course_number_range").unwrap(), "3000");
assert_eq!(params.get("txt_course_number_range_to").unwrap(), "3999");
}
#[test]
fn test_pagination() {
let params = SearchQuery::new().offset(20).max_results(10).to_params();
assert_eq!(params.get("pageOffset").unwrap(), "20");
assert_eq!(params.get("pageMaxSize").unwrap(), "10");
}
#[test]
fn test_format_time_9am() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(9 * 3600));
assert_eq!(h, "9");
assert_eq!(m, "0");
assert_eq!(mer, "AM");
}
#[test]
fn test_format_time_noon() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(12 * 3600));
assert_eq!(h, "12");
assert_eq!(m, "0");
assert_eq!(mer, "PM");
}
#[test]
fn test_format_time_1pm() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(13 * 3600));
assert_eq!(h, "1");
assert_eq!(m, "0");
assert_eq!(mer, "PM");
}
#[test]
fn test_format_time_930am() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(9 * 3600 + 30 * 60));
assert_eq!(h, "9");
assert_eq!(m, "30");
assert_eq!(mer, "AM");
}
#[test]
fn test_format_time_midnight() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(0));
assert_eq!(h, "0");
assert_eq!(m, "0");
assert_eq!(mer, "AM");
}
#[test]
fn test_time_params_in_query() {
let params = SearchQuery::new()
.start_time(Duration::from_secs(9 * 3600))
.end_time(Duration::from_secs(17 * 3600))
.to_params();
assert_eq!(params.get("select_start_hour").unwrap(), "9");
assert_eq!(params.get("select_start_ampm").unwrap(), "AM");
assert_eq!(params.get("select_end_hour").unwrap(), "5");
assert_eq!(params.get("select_end_ampm").unwrap(), "PM");
}
#[test]
fn test_multi_value_params() {
let params = SearchQuery::new()
.campus(vec!["MAIN".into(), "DT".into()])
.attributes(vec!["HONORS".into()])
.instructor(vec![1001, 1002])
.to_params();
assert_eq!(params.get("txt_campus").unwrap(), "MAIN,DT");
assert_eq!(params.get("txt_attribute").unwrap(), "HONORS");
assert_eq!(params.get("txt_instructor").unwrap(), "1001,1002");
}
#[test]
fn test_display_minimal() {
let display = SearchQuery::new().to_string();
assert_eq!(display, "offset=0, maxResults=8");
}
#[test]
fn test_display_with_fields() {
let display = SearchQuery::new()
.subject("CS")
.open_only(true)
.max_results(10)
.to_string();
assert!(display.contains("subject=CS"));
assert!(display.contains("openOnly=true"));
assert!(display.contains("maxResults=10"));
}
#[test]
fn test_full_query_param_count() {
let params = SearchQuery::new()
.subject("CS")
.title("Intro")
.course_reference_number("12345")
.keyword("programming")
.open_only(true)
.credits(3, 4)
.course_numbers(1000, 1999)
.offset(0)
.max_results(25)
.to_params();
// subject, title, crn, keyword, open_only, min_credits, max_credits,
// course_number_range, course_number_range_to, pageOffset, pageMaxSize = 11
assert_eq!(params.len(), 11);
}
}
impl std::fmt::Display for SearchQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new();
+76
View File
@@ -102,3 +102,79 @@ const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Json;
fn default_tracing_format() -> TracingFormat {
DEFAULT_TRACING_FORMAT
}
#[cfg(test)]
mod tests {
use super::*;
fn args_with_services(
services: Option<Vec<ServiceName>>,
disable: Option<Vec<ServiceName>>,
) -> Args {
Args {
tracing: TracingFormat::Pretty,
services,
disable_services: disable,
}
}
#[test]
fn test_default_enables_all_services() {
let result = determine_enabled_services(&args_with_services(None, None)).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_explicit_services_only_those() {
let result =
determine_enabled_services(&args_with_services(Some(vec![ServiceName::Web]), None))
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].as_str(), "web");
}
#[test]
fn test_disable_bot_leaves_web_and_scraper() {
let result =
determine_enabled_services(&args_with_services(None, Some(vec![ServiceName::Bot])))
.unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|s| s.as_str() != "bot"));
}
#[test]
fn test_disable_all_leaves_empty() {
let result = determine_enabled_services(&args_with_services(
None,
Some(vec![
ServiceName::Bot,
ServiceName::Web,
ServiceName::Scraper,
]),
))
.unwrap();
assert!(result.is_empty());
}
#[test]
fn test_both_specified_returns_error() {
let result = determine_enabled_services(&args_with_services(
Some(vec![ServiceName::Web]),
Some(vec![ServiceName::Bot]),
));
assert!(result.is_err());
}
#[test]
fn test_service_name_as_str() {
assert_eq!(ServiceName::Bot.as_str(), "bot");
assert_eq!(ServiceName::Web.as_str(), "web");
assert_eq!(ServiceName::Scraper.as_str(), "scraper");
}
#[test]
fn test_service_name_all() {
let all = ServiceName::all();
assert_eq!(all.len(), 3);
}
}
+98
View File
@@ -209,3 +209,101 @@ where
deserializer.deserialize_any(DurationVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct DurationWrapper {
#[serde(deserialize_with = "deserialize_duration")]
value: Duration,
}
fn parse(json: &str) -> Result<Duration, String> {
serde_json::from_str::<DurationWrapper>(json)
.map(|w| w.value)
.map_err(|e| e.to_string())
}
#[test]
fn test_duration_from_integer_seconds() {
let d = parse(r#"{"value": 30}"#).unwrap();
assert_eq!(d, Duration::from_secs(30));
}
#[test]
fn test_duration_from_string_seconds() {
let d = parse(r#"{"value": "30s"}"#).unwrap();
assert_eq!(d, Duration::from_secs(30));
}
#[test]
fn test_duration_from_string_minutes() {
let d = parse(r#"{"value": "2m"}"#).unwrap();
assert_eq!(d, Duration::from_secs(120));
}
#[test]
fn test_duration_from_string_milliseconds() {
let d = parse(r#"{"value": "1500ms"}"#).unwrap();
assert_eq!(d, Duration::from_millis(1500));
}
#[test]
fn test_duration_from_string_with_space() {
let d = parse(r#"{"value": "2 m"}"#).unwrap();
assert_eq!(d, Duration::from_secs(120));
}
#[test]
fn test_duration_from_string_multiple_units() {
let d = parse(r#"{"value": "1m 30s"}"#).unwrap();
assert_eq!(d, Duration::from_secs(90));
}
#[test]
fn test_duration_from_bare_number_string() {
let d = parse(r#"{"value": "45"}"#).unwrap();
assert_eq!(d, Duration::from_secs(45));
}
#[test]
fn test_duration_zero() {
let d = parse(r#"{"value": 0}"#).unwrap();
assert_eq!(d, Duration::from_secs(0));
}
#[test]
fn test_duration_negative_rejected() {
let err = parse(r#"{"value": -5}"#).unwrap_err();
assert!(err.contains("negative"), "expected negative error: {err}");
}
#[test]
fn test_duration_invalid_string_rejected() {
let err = parse(r#"{"value": "notaduration"}"#).unwrap_err();
assert!(
err.contains("Invalid duration"),
"expected invalid format error: {err}"
);
}
#[test]
fn test_default_config_values() {
assert_eq!(default_port(), 8080);
assert_eq!(default_shutdown_timeout(), Duration::from_secs(8));
assert_eq!(default_log_level(), "info");
}
#[test]
fn test_default_rate_limiting() {
let rl = default_rate_limiting();
assert_eq!(rl.session_rpm, 6);
assert_eq!(rl.search_rpm, 30);
assert_eq!(rl.metadata_rpm, 20);
assert_eq!(rl.reset_rpm, 10);
assert_eq!(rl.burst_allowance, 3);
}
}