mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
test: add comprehensive unit tests for query builder, CLI args, and config parsing
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user