diff --git a/Justfile b/Justfile index 5f00100..1d6443d 100644 --- a/Justfile +++ b/Justfile @@ -1,3 +1,4 @@ +set dotenv-load default_services := "bot,web,scraper" default: @@ -10,6 +11,28 @@ check: cargo nextest run bun run --cwd web typecheck 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: @@ -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) [parallel] 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 }} diff --git a/src/banner/query.rs b/src/banner/query.rs index e8c8703..cb9bf81 100644 --- a/src/banner/query.rs +++ b/src/banner/query.rs @@ -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(); diff --git a/src/cli.rs b/src/cli.rs index f711999..9cd6d75 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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>, + disable: Option>, + ) -> 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); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index b7eaae6..b8ccbea 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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 { + serde_json::from_str::(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); + } +}