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
+78
View File
@@ -1,3 +1,4 @@
set dotenv-load
default_services := "bot,web,scraper" default_services := "bot,web,scraper"
default: default:
@@ -10,6 +11,28 @@ check:
cargo nextest run cargo nextest run
bun run --cwd web typecheck bun run --cwd web typecheck
bun run --cwd web lint bun run --cwd web lint
bun run --cwd web test --run
# Run all tests (Rust + frontend)
test: test-rust test-web
# Run only Rust tests
test-rust *ARGS:
cargo nextest run {{ARGS}}
# Run only frontend tests
test-web:
bun run --cwd web test --run
# Quick check: clippy + tests only (skips formatting)
check-quick:
cargo clippy --all-features -- --deny warnings
cargo nextest run
bun run --cwd web typecheck
# Run the Banner API search demo (hits live UTSA API, ~20s)
search *ARGS:
cargo run -q --bin search -- {{ARGS}}
# Format all Rust and TypeScript code # Format all Rust and TypeScript code
format: format:
@@ -117,3 +140,58 @@ dev-build *ARGS='--services web --tracing pretty': build-frontend
# Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite) # Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite)
[parallel] [parallel]
dev *ARGS='--services web,bot': frontend (backend-dev ARGS) dev *ARGS='--services web,bot': frontend (backend-dev ARGS)
# Smoke test: start web server, hit API endpoints, verify responses
[script("bash")]
test-smoke port="18080":
set -euo pipefail
PORT={{port}}
cleanup() { kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null; }
# Start server in background
PORT=$PORT cargo run -q --no-default-features -- --services web --tracing json &
SERVER_PID=$!
trap cleanup EXIT
# Wait for server to be ready (up to 15s)
for i in $(seq 1 30); do
if curl -sf "http://localhost:$PORT/api/health" >/dev/null 2>&1; then break; fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "FAIL: server exited early"; exit 1; fi
sleep 0.5
done
PASS=0; FAIL=0
check() {
local label="$1" url="$2" expected="$3"
body=$(curl -sf "$url") || { echo "FAIL: $label - request failed"; FAIL=$((FAIL+1)); return; }
if echo "$body" | grep -q "$expected"; then
echo "PASS: $label"
PASS=$((PASS+1))
else
echo "FAIL: $label - expected '$expected' in: $body"
FAIL=$((FAIL+1))
fi
}
check "GET /api/health" "http://localhost:$PORT/api/health" '"status":"healthy"'
check "GET /api/status" "http://localhost:$PORT/api/status" '"version"'
check "GET /api/metrics" "http://localhost:$PORT/api/metrics" '"banner_api"'
# Test 404
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/nonexistent")
if [ "$STATUS" = "404" ]; then
echo "PASS: 404 on unknown route"
PASS=$((PASS+1))
else
echo "FAIL: expected 404, got $STATUS"
FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
alias b := bun
bun *ARGS:
cd web && bun {{ ARGS }}
+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 { impl std::fmt::Display for SearchQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new(); let mut parts = Vec::new();
+76
View File
@@ -102,3 +102,79 @@ const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Json;
fn default_tracing_format() -> TracingFormat { fn default_tracing_format() -> TracingFormat {
DEFAULT_TRACING_FORMAT 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) 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);
}
}