4 Commits

39 changed files with 1928 additions and 489 deletions
Generated
-22
View File
@@ -238,7 +238,6 @@ dependencies = [
"http 1.3.1",
"mime_guess",
"num-format",
"once_cell",
"poise",
"rand 0.9.2",
"rapidhash",
@@ -253,7 +252,6 @@ dependencies = [
"sqlx",
"thiserror 2.0.16",
"time",
"tl",
"tokio",
"tokio-util",
"tower-http",
@@ -1285,12 +1283,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -3368,12 +3360,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tl"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7"
[[package]]
name = "tokio"
version = "1.47.1"
@@ -3551,20 +3537,12 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.4",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
+2 -3
View File
@@ -36,20 +36,19 @@ sqlx = { version = "0.8.6", features = [
"chrono",
"json",
"macros",
"migrate",
] }
thiserror = "2.0.16"
time = "0.3.43"
tokio = { version = "1.47.1", features = ["full"] }
tokio-util = "0.7"
tl = "0.7.8"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
url = "2.5"
governor = "0.10.1"
once_cell = "1.21.3"
serde_path_to_error = "0.1.17"
num-format = "0.4.4"
tower-http = { version = "0.6.0", features = ["fs", "cors", "trace", "timeout"] }
tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout"] }
rust-embed = { version = "8.0", features = ["include-exclude"], optional = true }
mime_guess = { version = "2.0", optional = true }
clap = { version = "4.5", features = ["derive"] }
+3
View File
@@ -195,3 +195,6 @@ test-smoke port="18080":
alias b := bun
bun *ARGS:
cd web && bun {{ ARGS }}
sql *ARGS:
lazysql ${DATABASE_URL}
+9 -6
View File
@@ -12,6 +12,7 @@ use sqlx::postgres::PgPoolOptions;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use tracing::{error, info};
/// Main application struct containing all necessary components
@@ -36,7 +37,7 @@ impl App {
}
}))
.extract()
.expect("Failed to load config");
.context("Failed to load config")?;
// Check if the database URL is via private networking
let is_private = config.database_url.contains("railway.internal");
@@ -52,7 +53,7 @@ impl App {
.max_lifetime(Duration::from_secs(60 * 30))
.connect(&config.database_url)
.await
.expect("Failed to create database pool");
.context("Failed to create database pool")?;
info!(
is_private = is_private,
@@ -65,7 +66,7 @@ impl App {
sqlx::migrate!("./migrations")
.run(&db_pool)
.await
.expect("Failed to run database migrations");
.context("Failed to run database migrations")?;
info!("Database migrations completed successfully");
// Create BannerApi and AppState
@@ -73,7 +74,7 @@ impl App {
config.banner_base_url.clone(),
config.rate_limiting.clone(),
)
.expect("Failed to create BannerApi");
.context("Failed to create BannerApi")?;
let banner_api_arc = Arc::new(banner_api);
let app_state = AppState::new(banner_api_arc.clone(), db_pool.clone());
@@ -91,7 +92,7 @@ impl App {
pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> {
// Register enabled services with the manager
if services.contains(&ServiceName::Web) {
let web_service = Box::new(WebService::new(self.config.port));
let web_service = Box::new(WebService::new(self.config.port, self.app_state.clone()));
self.service_manager
.register_service(ServiceName::Web.as_str(), web_service);
}
@@ -100,6 +101,7 @@ impl App {
let scraper_service = Box::new(ScraperService::new(
self.db_pool.clone(),
self.banner_api.clone(),
self.app_state.service_statuses.clone(),
));
self.service_manager
.register_service(ServiceName::Scraper.as_str(), scraper_service);
@@ -130,12 +132,13 @@ impl App {
status_shutdown_rx,
)
.await
.expect("Failed to create Discord client");
.context("Failed to create Discord client")?;
let bot_service = Box::new(BotService::new(
client,
status_task_handle,
status_shutdown_tx,
self.app_state.service_statuses.clone(),
));
self.service_manager
+3 -30
View File
@@ -21,9 +21,9 @@ pub struct BannerApi {
base_url: String,
}
#[allow(dead_code)]
impl BannerApi {
/// Creates a new Banner API client.
#[allow(dead_code)]
pub fn new(base_url: String) -> Result<Self> {
Self::new_with_config(base_url, RateLimitingConfig::default())
}
@@ -142,11 +142,8 @@ impl BannerApi {
/// Performs a course search and handles common response processing.
#[tracing::instrument(
skip(self, query),
fields(
term = %term,
subject = %query.get_subject().unwrap_or(&"all".to_string())
)
skip(self, query, sort, sort_descending),
fields(term = %term)
)]
async fn perform_search(
&self,
@@ -231,30 +228,6 @@ impl BannerApi {
.await
}
/// Retrieves a list of instructors from the Banner API.
pub async fn get_instructors(
&self,
search: &str,
term: &str,
offset: i32,
max_results: i32,
) -> Result<Vec<Instructor>> {
self.get_list_endpoint("get_instructor", search, term, offset, max_results)
.await
}
/// Retrieves a list of campuses from the Banner API.
pub async fn get_campuses(
&self,
search: &str,
term: &str,
offset: i32,
max_results: i32,
) -> Result<Vec<Pair>> {
self.get_list_endpoint("get_campus", search, term, offset, max_results)
.await
}
/// Retrieves meeting time information for a course.
pub async fn get_course_meeting_time(
&self,
-3
View File
@@ -48,11 +48,9 @@ impl Middleware for TransparentMiddleware {
}
Ok(response)
} else {
let e = response.error_for_status_ref().unwrap_err();
warn!(
method = method,
path = path,
error = ?e,
status = response.status().as_u16(),
duration_ms = duration.as_millis(),
"Request failed"
@@ -64,7 +62,6 @@ impl Middleware for TransparentMiddleware {
warn!(
method = method,
path = path,
error = ?error,
duration_ms = duration.as_millis(),
"Request failed"
);
+1
View File
@@ -11,6 +11,7 @@ pub struct Pair {
pub type BannerTerm = Pair;
/// Represents an instructor in the Banner system
#[allow(dead_code)]
pub type Instructor = Pair;
impl BannerTerm {
+3 -23
View File
@@ -1,8 +1,8 @@
use bitflags::{Flags, bitflags};
use bitflags::{bitflags, Flags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, fmt::Display, str::FromStr};
use std::{cmp::Ordering, str::FromStr};
use super::terms::Term;
@@ -298,7 +298,7 @@ impl DateRange {
/// Get the number of weeks between start and end dates
pub fn weeks_duration(&self) -> u32 {
let duration = self.end.signed_duration_since(self.start);
duration.num_weeks() as u32
duration.num_weeks().max(0) as u32
}
/// Check if a specific date falls within this range
@@ -394,26 +394,6 @@ impl MeetingLocation {
}
}
impl Display for MeetingLocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MeetingLocation::Online => write!(f, "Online"),
MeetingLocation::InPerson {
campus,
building,
building_description,
room,
..
} => write!(
f,
"{campus} | {building_name} | {building_code} {room}",
building_name = building_description,
building_code = building,
),
}
}
}
/// Clean, parsed meeting schedule information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingScheduleInfo {
+206
View File
@@ -240,3 +240,209 @@ impl FromStr for Term {
Ok(Term { year, season })
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- Season::from_str ---
#[test]
fn test_season_from_str_fall() {
assert_eq!(Season::from_str("10").unwrap(), Season::Fall);
}
#[test]
fn test_season_from_str_spring() {
assert_eq!(Season::from_str("20").unwrap(), Season::Spring);
}
#[test]
fn test_season_from_str_summer() {
assert_eq!(Season::from_str("30").unwrap(), Season::Summer);
}
#[test]
fn test_season_from_str_invalid() {
for input in ["00", "40", "1", ""] {
assert!(
Season::from_str(input).is_err(),
"expected Err for {input:?}"
);
}
}
// --- Season Display ---
#[test]
fn test_season_display() {
assert_eq!(Season::Fall.to_string(), "Fall");
assert_eq!(Season::Spring.to_string(), "Spring");
assert_eq!(Season::Summer.to_string(), "Summer");
}
#[test]
fn test_season_to_str_roundtrip() {
for season in [Season::Fall, Season::Spring, Season::Summer] {
assert_eq!(Season::from_str(season.to_str()).unwrap(), season);
}
}
// --- Term::from_str ---
#[test]
fn test_term_from_str_valid_fall() {
let term = Term::from_str("202510").unwrap();
assert_eq!(term.year, 2025);
assert_eq!(term.season, Season::Fall);
}
#[test]
fn test_term_from_str_valid_spring() {
let term = Term::from_str("202520").unwrap();
assert_eq!(term.year, 2025);
assert_eq!(term.season, Season::Spring);
}
#[test]
fn test_term_from_str_valid_summer() {
let term = Term::from_str("202530").unwrap();
assert_eq!(term.year, 2025);
assert_eq!(term.season, Season::Summer);
}
#[test]
fn test_term_from_str_too_short() {
assert!(Term::from_str("20251").is_err());
}
#[test]
fn test_term_from_str_too_long() {
assert!(Term::from_str("2025100").is_err());
}
#[test]
fn test_term_from_str_empty() {
assert!(Term::from_str("").is_err());
}
#[test]
fn test_term_from_str_invalid_year_chars() {
assert!(Term::from_str("abcd10").is_err());
}
#[test]
fn test_term_from_str_invalid_season() {
assert!(Term::from_str("202540").is_err());
}
#[test]
fn test_term_from_str_year_below_range() {
assert!(Term::from_str("200010").is_err());
}
#[test]
fn test_term_display_roundtrip() {
for code in ["202510", "202520", "202530"] {
let term = Term::from_str(code).unwrap();
assert_eq!(term.to_string(), code);
}
}
// --- Term::get_status_for_date ---
#[test]
fn test_status_mid_spring() {
let date = NaiveDate::from_ymd_opt(2025, 2, 15).unwrap();
let status = Term::get_status_for_date(date);
assert!(
matches!(status, TermPoint::InTerm { current } if current.season == Season::Spring)
);
}
#[test]
fn test_status_mid_summer() {
let date = NaiveDate::from_ymd_opt(2025, 7, 1).unwrap();
let status = Term::get_status_for_date(date);
assert!(
matches!(status, TermPoint::InTerm { current } if current.season == Season::Summer)
);
}
#[test]
fn test_status_mid_fall() {
let date = NaiveDate::from_ymd_opt(2025, 10, 15).unwrap();
let status = Term::get_status_for_date(date);
assert!(matches!(status, TermPoint::InTerm { current } if current.season == Season::Fall));
}
#[test]
fn test_status_between_fall_and_spring() {
let date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let status = Term::get_status_for_date(date);
assert!(
matches!(status, TermPoint::BetweenTerms { next } if next.season == Season::Spring)
);
}
#[test]
fn test_status_between_spring_and_summer() {
let date = NaiveDate::from_ymd_opt(2025, 5, 15).unwrap();
let status = Term::get_status_for_date(date);
assert!(
matches!(status, TermPoint::BetweenTerms { next } if next.season == Season::Summer)
);
}
#[test]
fn test_status_between_summer_and_fall() {
let date = NaiveDate::from_ymd_opt(2025, 8, 16).unwrap();
let status = Term::get_status_for_date(date);
assert!(matches!(status, TermPoint::BetweenTerms { next } if next.season == Season::Fall));
}
#[test]
fn test_status_after_fall_end() {
let date = NaiveDate::from_ymd_opt(2025, 12, 15).unwrap();
let status = Term::get_status_for_date(date);
assert!(
matches!(status, TermPoint::BetweenTerms { next } if next.season == Season::Spring)
);
// Year should roll over: fall 2025 ends → next spring is 2026
let next_term = status.inner();
assert_eq!(next_term.year, 2026);
}
// --- TermPoint::inner ---
#[test]
fn test_term_point_inner() {
let in_term = TermPoint::InTerm {
current: Term {
year: 2025,
season: Season::Fall,
},
};
assert_eq!(
in_term.inner(),
&Term {
year: 2025,
season: Season::Fall
}
);
let between = TermPoint::BetweenTerms {
next: Term {
year: 2026,
season: Season::Spring,
},
};
assert_eq!(
between.inner(),
&Term {
year: 2026,
season: Season::Spring
}
);
}
}
+29 -18
View File
@@ -32,7 +32,6 @@ pub struct SearchQuery {
course_number_range: Option<Range>,
}
#[allow(dead_code)]
impl SearchQuery {
/// Creates a new SearchQuery with default values
pub fn new() -> Self {
@@ -68,6 +67,7 @@ impl SearchQuery {
}
/// Adds a keyword to the query
#[allow(dead_code)]
pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self {
match &mut self.keywords {
Some(keywords) => keywords.push(keyword.into()),
@@ -77,54 +77,63 @@ impl SearchQuery {
}
/// Sets whether to search for open courses only
#[allow(dead_code)]
pub fn open_only(mut self, open_only: bool) -> Self {
self.open_only = Some(open_only);
self
}
/// Sets the term part for the query
#[allow(dead_code)]
pub fn term_part(mut self, term_part: Vec<String>) -> Self {
self.term_part = Some(term_part);
self
}
/// Sets the campuses for the query
#[allow(dead_code)]
pub fn campus(mut self, campus: Vec<String>) -> Self {
self.campus = Some(campus);
self
}
/// Sets the instructional methods for the query
#[allow(dead_code)]
pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self {
self.instructional_method = Some(instructional_method);
self
}
/// Sets the attributes for the query
#[allow(dead_code)]
pub fn attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = Some(attributes);
self
}
/// Sets the instructors for the query
#[allow(dead_code)]
pub fn instructor(mut self, instructor: Vec<u64>) -> Self {
self.instructor = Some(instructor);
self
}
/// Sets the start time for the query
#[allow(dead_code)]
pub fn start_time(mut self, start_time: Duration) -> Self {
self.start_time = Some(start_time);
self
}
/// Sets the end time for the query
#[allow(dead_code)]
pub fn end_time(mut self, end_time: Duration) -> Self {
self.end_time = Some(end_time);
self
}
/// Sets the credit range for the query
#[allow(dead_code)]
pub fn credits(mut self, low: i32, high: i32) -> Self {
self.min_credits = Some(low);
self.max_credits = Some(high);
@@ -132,12 +141,14 @@ impl SearchQuery {
}
/// Sets the minimum credits for the query
#[allow(dead_code)]
pub fn min_credits(mut self, value: i32) -> Self {
self.min_credits = Some(value);
self
}
/// Sets the maximum credits for the query
#[allow(dead_code)]
pub fn max_credits(mut self, value: i32) -> Self {
self.max_credits = Some(value);
self
@@ -150,14 +161,16 @@ impl SearchQuery {
}
/// Sets the offset for pagination
#[allow(dead_code)]
pub fn offset(mut self, offset: i32) -> Self {
self.offset = offset;
self
}
/// Sets the maximum number of results to return
/// Clamped to a maximum of 500 to prevent excessive API load
pub fn max_results(mut self, max_results: i32) -> Self {
self.max_results = max_results;
self.max_results = max_results.clamp(1, 500);
self
}
@@ -253,27 +266,25 @@ impl SearchQuery {
}
}
/// Formats a Duration into hour, minute, and meridiem strings for Banner API
/// Formats a Duration into hour, minute, and meridiem strings for Banner API.
///
/// Uses 12-hour format: midnight = 12:00 AM, noon = 12:00 PM.
fn format_time_parameter(duration: Duration) -> (String, String, String) {
let total_minutes = duration.as_secs() / 60;
let hours = total_minutes / 60;
let minutes = total_minutes % 60;
let minute_str = minutes.to_string();
let meridiem = if hours >= 12 { "PM" } else { "AM" };
let hour_12 = match hours % 12 {
0 => 12,
h => h,
};
if hours >= 12 {
let meridiem = "PM".to_string();
let hour_str = if hours >= 13 {
(hours - 12).to_string()
} else {
hours.to_string()
};
(hour_str, minute_str, meridiem)
} else {
let meridiem = "AM".to_string();
let hour_str = hours.to_string();
(hour_str, minute_str, meridiem)
}
(
hour_12.to_string(),
minutes.to_string(),
meridiem.to_string(),
)
}
#[cfg(test)]
@@ -394,7 +405,7 @@ mod tests {
#[test]
fn test_format_time_midnight() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(0));
assert_eq!(h, "0");
assert_eq!(h, "12");
assert_eq!(m, "0");
assert_eq!(mer, "AM");
}
+123
View File
@@ -85,3 +85,126 @@ pub type SharedRateLimiter = Arc<BannerRateLimiter>;
pub fn create_shared_rate_limiter(config: Option<RateLimitingConfig>) -> SharedRateLimiter {
Arc::new(BannerRateLimiter::new(config.unwrap_or_default()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_with_default_config() {
let _limiter = BannerRateLimiter::new(RateLimitingConfig::default());
}
#[test]
fn test_new_with_custom_config() {
let config = RateLimitingConfig {
session_rpm: 10,
search_rpm: 30,
metadata_rpm: 20,
reset_rpm: 15,
burst_allowance: 5,
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
fn test_new_with_minimum_valid_values() {
let config = RateLimitingConfig {
session_rpm: 1,
search_rpm: 1,
metadata_rpm: 1,
reset_rpm: 1,
burst_allowance: 1,
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
fn test_new_with_high_rpm_values() {
let config = RateLimitingConfig {
session_rpm: 10000,
search_rpm: 10000,
metadata_rpm: 10000,
reset_rpm: 10000,
burst_allowance: 1,
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
fn test_default_impl() {
let _limiter = BannerRateLimiter::default();
}
#[test]
#[should_panic]
fn test_new_panics_on_zero_session_rpm() {
let config = RateLimitingConfig {
session_rpm: 0,
..RateLimitingConfig::default()
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
#[should_panic]
fn test_new_panics_on_zero_search_rpm() {
let config = RateLimitingConfig {
search_rpm: 0,
..RateLimitingConfig::default()
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
#[should_panic]
fn test_new_panics_on_zero_metadata_rpm() {
let config = RateLimitingConfig {
metadata_rpm: 0,
..RateLimitingConfig::default()
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
#[should_panic]
fn test_new_panics_on_zero_reset_rpm() {
let config = RateLimitingConfig {
reset_rpm: 0,
..RateLimitingConfig::default()
};
let _limiter = BannerRateLimiter::new(config);
}
#[test]
#[should_panic]
fn test_new_panics_on_zero_burst_allowance() {
let config = RateLimitingConfig {
burst_allowance: 0,
..RateLimitingConfig::default()
};
let _limiter = BannerRateLimiter::new(config);
}
#[tokio::test]
async fn test_wait_for_permission_completes() {
let limiter = BannerRateLimiter::default();
let timeout_duration = std::time::Duration::from_secs(1);
for request_type in [
RequestType::Session,
RequestType::Search,
RequestType::Metadata,
RequestType::Reset,
] {
let result =
tokio::time::timeout(timeout_duration, limiter.wait_for_permission(request_type))
.await;
assert!(
result.is_ok(),
"wait_for_permission timed out for {:?}",
request_type
);
}
}
}
+105 -8
View File
@@ -7,13 +7,12 @@ use cookie::Cookie;
use dashmap::DashMap;
use governor::state::InMemoryState;
use governor::{Quota, RateLimiter};
use once_cell::sync::Lazy;
use rand::distr::{Alphanumeric, SampleString};
use reqwest_middleware::ClientWithMiddleware;
use std::collections::{HashMap, VecDeque};
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, Notify};
use tracing::{debug, info, trace};
@@ -23,9 +22,9 @@ const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes
// A global rate limiter to ensure we only try to create one new session every 10 seconds,
// preventing us from overwhelming the server with session creation requests.
static SESSION_CREATION_RATE_LIMITER: Lazy<
static SESSION_CREATION_RATE_LIMITER: LazyLock<
RateLimiter<governor::state::direct::NotKeyed, InMemoryState, governor::clock::DefaultClock>,
> = Lazy::new(|| RateLimiter::direct(Quota::with_period(Duration::from_secs(10)).unwrap()));
> = LazyLock::new(|| RateLimiter::direct(Quota::with_period(Duration::from_secs(10)).unwrap()));
/// Represents an active anonymous session within the Banner API.
/// Identified by multiple persistent cookies, as well as a client-generated "unique session ID".
@@ -63,16 +62,16 @@ pub fn nonce() -> String {
impl BannerSession {
/// Creates a new session
pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Result<Self> {
pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Self {
let now = Instant::now();
Ok(Self {
Self {
created_at: now,
last_activity: None,
unique_session_id: unique_session_id.to_string(),
jsessionid: jsessionid.to_string(),
ssb_cookie: ssb_cookie.to_string(),
})
}
}
/// Returns the unique session ID
@@ -101,6 +100,104 @@ impl BannerSession {
pub fn been_used(&self) -> bool {
self.last_activity.is_some()
}
#[cfg(test)]
pub(crate) fn new_with_created_at(
unique_session_id: &str,
jsessionid: &str,
ssb_cookie: &str,
created_at: Instant,
) -> Self {
Self {
unique_session_id: unique_session_id.to_string(),
created_at,
last_activity: None,
jsessionid: jsessionid.to_string(),
ssb_cookie: ssb_cookie.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_session_creates_session() {
let session = BannerSession::new("sess-1", "JSID123", "SSB456");
assert_eq!(session.id(), "sess-1");
}
#[test]
fn test_fresh_session_not_expired() {
let session = BannerSession::new("sess-1", "JSID123", "SSB456");
assert!(!session.is_expired());
}
#[test]
fn test_fresh_session_not_been_used() {
let session = BannerSession::new("sess-1", "JSID123", "SSB456");
assert!(!session.been_used());
}
#[test]
fn test_touch_marks_used() {
let mut session = BannerSession::new("sess-1", "JSID123", "SSB456");
session.touch();
assert!(session.been_used());
}
#[test]
fn test_touched_session_not_expired() {
let mut session = BannerSession::new("sess-1", "JSID123", "SSB456");
session.touch();
assert!(!session.is_expired());
}
#[test]
fn test_cookie_format() {
let session = BannerSession::new("sess-1", "JSID123", "SSB456");
assert_eq!(session.cookie(), "JSESSIONID=JSID123; SSB_COOKIE=SSB456");
}
#[test]
fn test_id_returns_unique_session_id() {
let session = BannerSession::new("my-unique-id", "JSID123", "SSB456");
assert_eq!(session.id(), "my-unique-id");
}
#[test]
fn test_expired_session() {
let session = BannerSession::new_with_created_at(
"sess-old",
"JSID123",
"SSB456",
Instant::now() - Duration::from_secs(26 * 60),
);
assert!(session.is_expired());
}
#[test]
fn test_not_quite_expired_session() {
let session = BannerSession::new_with_created_at(
"sess-recent",
"JSID123",
"SSB456",
Instant::now() - Duration::from_secs(24 * 60),
);
assert!(!session.is_expired());
}
#[test]
fn test_session_at_expiry_boundary() {
let session = BannerSession::new_with_created_at(
"sess-boundary",
"JSID123",
"SSB456",
Instant::now() - Duration::from_secs(25 * 60 + 1),
);
assert!(session.is_expired());
}
}
/// A smart pointer that returns a BannerSession to the pool when dropped.
@@ -355,7 +452,7 @@ impl SessionPool {
self.select_term(&term.to_string(), &unique_session_id, &cookie_header)
.await?;
BannerSession::new(&unique_session_id, jsessionid, ssb_cookie)
Ok(BannerSession::new(&unique_session_id, jsessionid, ssb_cookie))
}
/// Retrieves a list of terms from the Banner API.
+1 -1
View File
@@ -2,5 +2,5 @@
/// Returns a browser-like user agent string.
pub fn user_agent() -> &'static str {
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
}
+82 -127
View File
@@ -2,116 +2,78 @@
use crate::banner::{Course, MeetingDays, MeetingScheduleInfo, WeekdayExt};
use crate::bot::{Context, Error, utils};
use chrono::{Datelike, NaiveDate, Utc};
use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday};
use serenity::all::CreateAttachment;
use tracing::info;
/// Represents a holiday or special day that should be excluded from class schedules
#[derive(Debug, Clone)]
enum Holiday {
/// A single-day holiday
Single { month: u32, day: u32 },
/// A multi-day holiday range
Range {
month: u32,
start_day: u32,
end_day: u32,
},
/// Find the nth occurrence of a weekday in a given month/year (1-based).
fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option<NaiveDate> {
let first = NaiveDate::from_ymd_opt(year, month, 1)?;
let days_ahead = (weekday.num_days_from_monday() as i64
- first.weekday().num_days_from_monday() as i64)
.rem_euclid(7) as u32;
let day = 1 + days_ahead + 7 * (n - 1);
NaiveDate::from_ymd_opt(year, month, day)
}
impl Holiday {
/// Check if a specific date falls within this holiday
fn contains_date(&self, date: NaiveDate) -> bool {
match self {
Holiday::Single { month, day, .. } => date.month() == *month && date.day() == *day,
Holiday::Range {
month,
start_day,
end_day,
..
} => date.month() == *month && date.day() >= *start_day && date.day() <= *end_day,
}
}
/// Get all dates in this holiday for a given year
fn get_dates_for_year(&self, year: i32) -> Vec<NaiveDate> {
match self {
Holiday::Single { month, day, .. } => {
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, *day) {
vec![date]
} else {
Vec::new()
}
}
Holiday::Range {
month,
start_day,
end_day,
..
} => {
let mut dates = Vec::new();
for day in *start_day..=*end_day {
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, day) {
dates.push(date);
}
}
dates
}
}
}
/// Compute a consecutive range of dates starting from `start` for `count` days.
fn date_range(start: NaiveDate, count: i64) -> Vec<NaiveDate> {
(0..count).filter_map(|i| start.checked_add_signed(Duration::days(i))).collect()
}
/// University holidays excluded from class schedules.
/// Compute university holidays for a given year.
///
/// WARNING: These dates are specific to the UTSA 2024-2025 academic calendar and must be
/// updated each academic year. Many of these holidays fall on different dates annually
/// (e.g., Labor Day is the first Monday of September, Thanksgiving is the fourth Thursday
/// of November). Ideally these would be loaded from a configuration file or computed
/// dynamically from federal/university calendar rules.
// TODO: Load holiday dates from configuration or compute dynamically per academic year.
const UNIVERSITY_HOLIDAYS: &[(&str, Holiday)] = &[
("Labor Day", Holiday::Single { month: 9, day: 1 }),
(
"Fall Break",
Holiday::Range {
month: 10,
start_day: 13,
end_day: 14,
},
),
(
"Unspecified Holiday",
Holiday::Single { month: 11, day: 26 },
),
(
"Thanksgiving",
Holiday::Range {
month: 11,
start_day: 28,
end_day: 29,
},
),
("Student Study Day", Holiday::Single { month: 12, day: 5 }),
(
"Winter Holiday",
Holiday::Range {
month: 12,
start_day: 23,
end_day: 31,
},
),
("New Year's Day", Holiday::Single { month: 1, day: 1 }),
("MLK Day", Holiday::Single { month: 1, day: 20 }),
(
"Spring Break",
Holiday::Range {
month: 3,
start_day: 10,
end_day: 15,
},
),
("Student Study Day", Holiday::Single { month: 5, day: 9 }),
];
/// Federal holidays use weekday-of-month rules so they're correct for any year.
/// University-specific breaks (Fall Break, Spring Break, Winter Holiday) are derived
/// from anchoring federal holidays or using UTSA's typical scheduling patterns.
fn compute_holidays_for_year(year: i32) -> Vec<(&'static str, Vec<NaiveDate>)> {
let mut holidays = Vec::new();
// Labor Day: 1st Monday of September
if let Some(d) = nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
holidays.push(("Labor Day", vec![d]));
}
// Fall Break: Mon-Tue of Columbus Day week (2nd Monday of October + Tuesday)
if let Some(mon) = nth_weekday_of_month(year, 10, Weekday::Mon, 2) {
holidays.push(("Fall Break", date_range(mon, 2)));
}
// Day before Thanksgiving: Wednesday before 4th Thursday of November
if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4)
&& let Some(wed) = thu.checked_sub_signed(Duration::days(1))
{
holidays.push(("Day Before Thanksgiving", vec![wed]));
}
// Thanksgiving: 4th Thursday of November + Friday
if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
holidays.push(("Thanksgiving", date_range(thu, 2)));
}
// Winter Holiday: Dec 23-31
if let Some(start) = NaiveDate::from_ymd_opt(year, 12, 23) {
holidays.push(("Winter Holiday", date_range(start, 9)));
}
// New Year's Day: January 1
if let Some(d) = NaiveDate::from_ymd_opt(year, 1, 1) {
holidays.push(("New Year's Day", vec![d]));
}
// MLK Day: 3rd Monday of January
if let Some(d) = nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
holidays.push(("MLK Day", vec![d]));
}
// Spring Break: full week (Mon-Sat) starting the 2nd or 3rd Monday of March
// UTSA typically uses the 2nd full week of March
if let Some(mon) = nth_weekday_of_month(year, 3, Weekday::Mon, 2) {
holidays.push(("Spring Break", date_range(mon, 6)));
}
holidays
}
/// Generate an ICS file for a course
#[poise::command(slash_command, prefix_command)]
@@ -329,10 +291,16 @@ fn generate_event_content(
}
// Collect holiday names for reporting
let start_year = meeting_time.date_range.start.year();
let end_year = meeting_time.date_range.end.year();
let all_holidays: Vec<_> = (start_year..=end_year)
.flat_map(compute_holidays_for_year)
.collect();
let mut holiday_names = Vec::new();
for (holiday_name, holiday) in UNIVERSITY_HOLIDAYS {
for (holiday_name, holiday_dates) in &all_holidays {
for &exception_date in &holiday_exceptions {
if holiday.contains_date(exception_date) {
if holiday_dates.contains(&exception_date) {
holiday_names.push(format!(
"{} ({})",
holiday_name,
@@ -344,6 +312,7 @@ fn generate_event_content(
holiday_names.sort();
holiday_names.dedup();
event_content.push_str("END:VEVENT\r\n");
return Ok((event_content, holiday_names));
}
}
@@ -362,32 +331,18 @@ fn class_meets_on_date(meeting_time: &MeetingScheduleInfo, date: NaiveDate) -> b
/// Get holiday dates that fall within the course date range and would conflict with class meetings
fn get_holiday_exceptions(meeting_time: &MeetingScheduleInfo) -> Vec<NaiveDate> {
let mut exceptions = Vec::new();
// Get the year range from the course date range
let start_year = meeting_time.date_range.start.year();
let end_year = meeting_time.date_range.end.year();
for (_, holiday) in UNIVERSITY_HOLIDAYS {
// Check for the holiday in each year of the course
for year in start_year..=end_year {
let holiday_dates = holiday.get_dates_for_year(year);
for holiday_date in holiday_dates {
// Check if the holiday falls within the course date range
if holiday_date >= meeting_time.date_range.start
&& holiday_date <= meeting_time.date_range.end
{
// Check if the class would actually meet on this day
if class_meets_on_date(meeting_time, holiday_date) {
exceptions.push(holiday_date);
}
}
}
}
}
exceptions
(start_year..=end_year)
.flat_map(compute_holidays_for_year)
.flat_map(|(_, dates)| dates)
.filter(|&date| {
date >= meeting_time.date_range.start
&& date <= meeting_time.date_range.end
&& class_meets_on_date(meeting_time, date)
})
.collect()
}
/// Generate EXDATE property for holiday exceptions
+109 -2
View File
@@ -24,8 +24,8 @@ pub async fn search(
// Defer the response since this might take a while
ctx.defer().await?;
// Build the search query
let mut query = SearchQuery::new().credits(3, 6);
// Build the search query — no default credit filter so all courses are visible
let mut query = SearchQuery::new();
if let Some(title) = title {
query = query.title(title);
@@ -140,3 +140,110 @@ fn parse_course_code(input: &str) -> Result<(i32, i32), Error> {
Err(anyhow!("Invalid course code format"))
}
#[cfg(test)]
mod tests {
use super::*;
// --- Single codes ---
#[test]
fn test_parse_single_code() {
assert_eq!(parse_course_code("3743").unwrap(), (3743, 3743));
}
#[test]
fn test_parse_single_code_boundaries() {
assert_eq!(parse_course_code("1000").unwrap(), (1000, 1000));
assert_eq!(parse_course_code("9999").unwrap(), (9999, 9999));
}
#[test]
fn test_parse_single_code_below_range() {
assert!(parse_course_code("0999").is_err());
}
#[test]
fn test_parse_single_code_wrong_length() {
assert!(parse_course_code("123").is_err());
}
#[test]
fn test_parse_single_code_non_numeric() {
assert!(parse_course_code("abcd").is_err());
}
#[test]
fn test_parse_single_code_trimmed() {
assert_eq!(parse_course_code(" 3743 ").unwrap(), (3743, 3743));
}
// --- Ranges ---
#[test]
fn test_parse_range_full() {
assert_eq!(parse_course_code("3000-3999").unwrap(), (3000, 3999));
}
#[test]
fn test_parse_range_same() {
assert_eq!(parse_course_code("3000-3000").unwrap(), (3000, 3000));
}
#[test]
fn test_parse_range_open() {
assert_eq!(parse_course_code("3000-").unwrap(), (3000, 9999));
}
#[test]
fn test_parse_range_inverted() {
assert!(parse_course_code("5000-3000").is_err());
}
#[test]
fn test_parse_range_below_1000() {
assert!(parse_course_code("500-999").is_err());
}
#[test]
fn test_parse_range_above_9999() {
assert!(parse_course_code("9000-10000").is_err());
}
#[test]
fn test_parse_range_full_valid() {
assert_eq!(parse_course_code("1000-9999").unwrap(), (1000, 9999));
}
// --- Wildcards ---
#[test]
fn test_parse_wildcard_one_x() {
assert_eq!(parse_course_code("300x").unwrap(), (3000, 3009));
}
#[test]
fn test_parse_wildcard_two_x() {
assert_eq!(parse_course_code("30xx").unwrap(), (3000, 3099));
}
#[test]
fn test_parse_wildcard_three_x() {
assert_eq!(parse_course_code("3xxx").unwrap(), (3000, 3999));
}
#[test]
fn test_parse_wildcard_9xxx() {
assert_eq!(parse_course_code("9xxx").unwrap(), (9000, 9999));
}
#[test]
fn test_parse_wildcard_wrong_length() {
assert!(parse_course_code("3xxxx").is_err());
}
#[test]
fn test_parse_wildcard_0xxx() {
assert!(parse_course_code("0xxx").is_err());
}
}
-13
View File
@@ -120,16 +120,3 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_batch_returns_ok() {
// This is a basic compile-time test
// Runtime tests would require sqlx::test macro and a test database
let courses: Vec<Course> = vec![];
assert_eq!(courses.len(), 0);
}
}
+1
View File
@@ -2,3 +2,4 @@
pub mod batch;
pub mod models;
pub mod scrape_jobs;
+170
View File
@@ -0,0 +1,170 @@
//! Database operations for scrape job queue management.
use crate::data::models::{ScrapeJob, ScrapePriority, TargetType};
use crate::error::Result;
use sqlx::PgPool;
use std::collections::HashSet;
/// Atomically fetch and lock the next available scrape job.
///
/// Uses `FOR UPDATE SKIP LOCKED` to allow multiple workers to poll the queue
/// concurrently without conflicts. Only jobs that are unlocked and ready to
/// execute (based on `execute_at`) are considered.
///
/// # Arguments
/// * `db_pool` - PostgreSQL connection pool
///
/// # Returns
/// * `Ok(Some(job))` if a job was successfully fetched and locked
/// * `Ok(None)` if no jobs are available
pub async fn fetch_and_lock_job(db_pool: &PgPool) -> Result<Option<ScrapeJob>> {
let mut tx = db_pool.begin().await?;
let job = sqlx::query_as::<_, ScrapeJob>(
"SELECT * FROM scrape_jobs WHERE locked_at IS NULL AND execute_at <= NOW() ORDER BY priority DESC, execute_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED"
)
.fetch_optional(&mut *tx)
.await?;
if let Some(ref job) = job {
sqlx::query("UPDATE scrape_jobs SET locked_at = NOW() WHERE id = $1")
.bind(job.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(job)
}
/// Delete a scrape job by ID.
///
/// Typically called after a job has been successfully processed or permanently failed.
///
/// # Arguments
/// * `job_id` - The database ID of the job to delete
/// * `db_pool` - PostgreSQL connection pool
pub async fn delete_job(job_id: i32, db_pool: &PgPool) -> Result<()> {
sqlx::query("DELETE FROM scrape_jobs WHERE id = $1")
.bind(job_id)
.execute(db_pool)
.await?;
Ok(())
}
/// Unlock a scrape job by clearing its `locked_at` timestamp.
///
/// Used to release a job back to the queue, e.g. during graceful shutdown.
///
/// # Arguments
/// * `job_id` - The database ID of the job to unlock
/// * `db_pool` - PostgreSQL connection pool
pub async fn unlock_job(job_id: i32, db_pool: &PgPool) -> Result<()> {
sqlx::query("UPDATE scrape_jobs SET locked_at = NULL WHERE id = $1")
.bind(job_id)
.execute(db_pool)
.await?;
Ok(())
}
/// Atomically unlock a job and increment its retry count.
///
/// Returns whether the job still has retries remaining. This is determined
/// atomically in the database to avoid race conditions between workers.
///
/// # Arguments
/// * `job_id` - The database ID of the job
/// * `max_retries` - Maximum number of retries allowed for this job
/// * `db_pool` - PostgreSQL connection pool
///
/// # Returns
/// * `Ok(true)` if the job was unlocked and retries remain
/// * `Ok(false)` if the job has exhausted its retries
pub async fn unlock_and_increment_retry(
job_id: i32,
max_retries: i32,
db_pool: &PgPool,
) -> Result<bool> {
let result = sqlx::query_scalar::<_, Option<i32>>(
"UPDATE scrape_jobs
SET locked_at = NULL, retry_count = retry_count + 1
WHERE id = $1
RETURNING CASE WHEN retry_count < $2 THEN retry_count ELSE NULL END",
)
.bind(job_id)
.bind(max_retries)
.fetch_one(db_pool)
.await?;
Ok(result.is_some())
}
/// Find existing unlocked job payloads matching the given target type and candidates.
///
/// Returns a set of stringified JSON payloads that already exist in the queue,
/// used for deduplication when scheduling new jobs.
///
/// # Arguments
/// * `target_type` - The target type to filter by
/// * `candidate_payloads` - Candidate payloads to check against existing jobs
/// * `db_pool` - PostgreSQL connection pool
///
/// # Returns
/// A `HashSet` of stringified JSON payloads that already have pending jobs
pub async fn find_existing_job_payloads(
target_type: TargetType,
candidate_payloads: &[serde_json::Value],
db_pool: &PgPool,
) -> Result<HashSet<String>> {
let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT target_payload FROM scrape_jobs
WHERE target_type = $1 AND target_payload = ANY($2) AND locked_at IS NULL",
)
.bind(target_type)
.bind(candidate_payloads)
.fetch_all(db_pool)
.await?;
let existing_payloads = existing_jobs
.into_iter()
.map(|(payload,)| payload.to_string())
.collect();
Ok(existing_payloads)
}
/// Batch insert scrape jobs in a single transaction.
///
/// All jobs are inserted with `execute_at` set to the current time.
///
/// # Arguments
/// * `jobs` - Slice of `(payload, target_type, priority)` tuples to insert
/// * `db_pool` - PostgreSQL connection pool
pub async fn batch_insert_jobs(
jobs: &[(serde_json::Value, TargetType, ScrapePriority)],
db_pool: &PgPool,
) -> Result<()> {
if jobs.is_empty() {
return Ok(());
}
let now = chrono::Utc::now();
let mut tx = db_pool.begin().await?;
for (payload, target_type, priority) in jobs {
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)"
)
.bind(target_type)
.bind(payload)
.bind(priority)
.bind(now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
+1
View File
@@ -11,5 +11,6 @@ pub mod scraper;
pub mod services;
pub mod signals;
pub mod state;
pub mod status;
pub mod utils;
pub mod web;
+8 -6
View File
@@ -18,6 +18,8 @@ mod scraper;
mod services;
mod signals;
mod state;
#[allow(dead_code)]
mod status;
mod web;
#[tokio::main]
@@ -31,17 +33,17 @@ async fn main() -> ExitCode {
let enabled_services: Vec<ServiceName> =
determine_enabled_services(&args).expect("Failed to determine enabled services");
// Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application");
// Setup logging — must happen before any info!() calls to avoid silently dropped logs
setup_logging(app.config(), args.tracing);
info!(
enabled_services = ?enabled_services,
"services configuration loaded"
);
// Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application");
// Setup logging
setup_logging(app.config(), args.tracing);
// Log application startup context
info!(
version = env!("CARGO_PKG_VERSION"),
+90 -44
View File
@@ -5,58 +5,24 @@ use crate::data::models::TargetType;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::fmt;
use thiserror::Error;
/// Errors that can occur during job parsing
#[derive(Debug)]
#[derive(Debug, Error)]
pub enum JobParseError {
InvalidJson(serde_json::Error),
#[error("Invalid JSON in job payload: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("Unsupported target type: {0:?}")]
UnsupportedTargetType(TargetType),
}
impl fmt::Display for JobParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JobParseError::InvalidJson(e) => write!(f, "Invalid JSON in job payload: {}", e),
JobParseError::UnsupportedTargetType(t) => {
write!(f, "Unsupported target type: {:?}", t)
}
}
}
}
impl std::error::Error for JobParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
JobParseError::InvalidJson(e) => Some(e),
_ => None,
}
}
}
/// Errors that can occur during job processing
#[derive(Debug)]
#[derive(Debug, Error)]
pub enum JobError {
Recoverable(anyhow::Error), // API failures, network issues
Unrecoverable(anyhow::Error), // Parse errors, corrupted data
}
impl fmt::Display for JobError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JobError::Recoverable(e) => write!(f, "Recoverable error: {}", e),
JobError::Unrecoverable(e) => write!(f, "Unrecoverable error: {}", e),
}
}
}
impl std::error::Error for JobError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
JobError::Recoverable(e) => e.source(),
JobError::Unrecoverable(e) => e.source(),
}
}
#[error("Recoverable error: {0}")]
Recoverable(#[source] anyhow::Error),
#[error("Unrecoverable error: {0}")]
Unrecoverable(#[source] anyhow::Error),
}
/// Common trait interface for all job types
@@ -102,3 +68,83 @@ impl JobType {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// --- Valid dispatch ---
#[test]
fn test_from_target_subject_valid() {
let result =
JobType::from_target_type_and_payload(TargetType::Subject, json!({"subject": "CS"}));
assert!(matches!(result, Ok(JobType::Subject(_))));
}
#[test]
fn test_from_target_subject_empty_string() {
let result =
JobType::from_target_type_and_payload(TargetType::Subject, json!({"subject": ""}));
assert!(matches!(result, Ok(JobType::Subject(_))));
}
// --- Invalid JSON ---
#[test]
fn test_from_target_subject_missing_field() {
let result = JobType::from_target_type_and_payload(TargetType::Subject, json!({}));
assert!(matches!(result, Err(JobParseError::InvalidJson(_))));
}
#[test]
fn test_from_target_subject_wrong_type() {
let result =
JobType::from_target_type_and_payload(TargetType::Subject, json!({"subject": 123}));
assert!(matches!(result, Err(JobParseError::InvalidJson(_))));
}
#[test]
fn test_from_target_subject_null_payload() {
let result = JobType::from_target_type_and_payload(TargetType::Subject, json!(null));
assert!(matches!(result, Err(JobParseError::InvalidJson(_))));
}
// --- Unsupported target types ---
#[test]
fn test_from_target_unsupported_variants() {
let unsupported = [
TargetType::CourseRange,
TargetType::CrnList,
TargetType::SingleCrn,
];
for target_type in unsupported {
let result =
JobType::from_target_type_and_payload(target_type, json!({"subject": "CS"}));
assert!(
matches!(result, Err(JobParseError::UnsupportedTargetType(_))),
"expected UnsupportedTargetType for {target_type:?}"
);
}
}
// --- Error Display ---
#[test]
fn test_job_parse_error_display() {
let invalid_json_err =
JobType::from_target_type_and_payload(TargetType::Subject, json!(null)).unwrap_err();
let display = invalid_json_err.to_string();
assert!(display.contains("Invalid JSON"), "got: {display}");
let unsupported_err =
JobType::from_target_type_and_payload(TargetType::CrnList, json!({})).unwrap_err();
let display = unsupported_err.to_string();
assert!(
display.contains("Unsupported target type"),
"got: {display}"
);
}
}
+2 -2
View File
@@ -39,14 +39,14 @@ impl Job for SubjectJob {
if let Some(courses_from_api) = search_result.data {
info!(
subject = subject_code,
subject = %subject_code,
count = courses_from_api.len(),
"Found courses"
);
batch_upsert_courses(&courses_from_api, db_pool).await?;
}
debug!(subject = subject_code, "Subject job completed");
debug!(subject = %subject_code, "Subject job completed");
Ok(())
}
+6 -1
View File
@@ -4,6 +4,7 @@ pub mod worker;
use crate::banner::BannerApi;
use crate::services::Service;
use crate::status::{ServiceStatus, ServiceStatusRegistry};
use sqlx::PgPool;
use std::sync::Arc;
use tokio::sync::broadcast;
@@ -20,6 +21,7 @@ use self::worker::Worker;
pub struct ScraperService {
db_pool: PgPool,
banner_api: Arc<BannerApi>,
service_statuses: ServiceStatusRegistry,
scheduler_handle: Option<JoinHandle<()>>,
worker_handles: Vec<JoinHandle<()>>,
shutdown_tx: Option<broadcast::Sender<()>>,
@@ -27,10 +29,11 @@ pub struct ScraperService {
impl ScraperService {
/// Creates a new `ScraperService`.
pub fn new(db_pool: PgPool, banner_api: Arc<BannerApi>) -> Self {
pub fn new(db_pool: PgPool, banner_api: Arc<BannerApi>, service_statuses: ServiceStatusRegistry) -> Self {
Self {
db_pool,
banner_api,
service_statuses,
scheduler_handle: None,
worker_handles: Vec::new(),
shutdown_tx: None,
@@ -66,6 +69,7 @@ impl ScraperService {
worker_count = self.worker_handles.len(),
"Spawned worker tasks"
);
self.service_statuses.set("scraper", ServiceStatus::Active);
}
}
@@ -82,6 +86,7 @@ impl Service for ScraperService {
}
async fn shutdown(&mut self) -> Result<(), anyhow::Error> {
self.service_statuses.set("scraper", ServiceStatus::Disabled);
info!("Shutting down scraper service");
// Send shutdown signal to all tasks
+12 -27
View File
@@ -1,5 +1,6 @@
use crate::banner::{BannerApi, Term};
use crate::data::models::{ScrapePriority, TargetType};
use crate::data::scrape_jobs;
use crate::error::Result;
use crate::scraper::jobs::subject::SubjectJob;
use serde_json::json;
@@ -123,21 +124,13 @@ impl Scheduler {
.collect();
// Query existing jobs for all subjects in a single query
let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT target_payload FROM scrape_jobs
WHERE target_type = $1 AND target_payload = ANY($2) AND locked_at IS NULL",
let existing_payloads = scrape_jobs::find_existing_job_payloads(
TargetType::Subject,
&subject_payloads,
db_pool,
)
.bind(TargetType::Subject)
.bind(&subject_payloads)
.fetch_all(db_pool)
.await?;
// Convert to a HashSet for efficient lookup
let existing_payloads: std::collections::HashSet<String> = existing_jobs
.into_iter()
.map(|(payload,)| payload.to_string())
.collect();
// Filter out subjects that already have jobs and prepare new jobs
let mut skipped_count = 0;
let new_jobs: Vec<_> = subjects
@@ -162,24 +155,16 @@ impl Scheduler {
// Insert all new jobs in a single batch
if !new_jobs.is_empty() {
let now = chrono::Utc::now();
let mut tx = db_pool.begin().await?;
for (payload, subject_code) in new_jobs {
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)"
)
.bind(TargetType::Subject)
.bind(&payload)
.bind(ScrapePriority::Low)
.bind(now)
.execute(&mut *tx)
.await?;
for (_, subject_code) in &new_jobs {
debug!(subject = subject_code, "New job enqueued for subject");
}
tx.commit().await?;
let jobs: Vec<_> = new_jobs
.into_iter()
.map(|(payload, _)| (payload, TargetType::Subject, ScrapePriority::Low))
.collect();
scrape_jobs::batch_insert_jobs(&jobs, db_pool).await?;
}
debug!("Job scheduling complete");
+7 -42
View File
@@ -1,5 +1,6 @@
use crate::banner::{BannerApi, BannerApiError};
use crate::data::models::ScrapeJob;
use crate::data::scrape_jobs;
use crate::error::Result;
use crate::scraper::jobs::{JobError, JobType};
use sqlx::PgPool;
@@ -83,24 +84,7 @@ impl Worker {
/// This uses a `FOR UPDATE SKIP LOCKED` query to ensure that multiple
/// workers can poll the queue concurrently without conflicts.
async fn fetch_and_lock_job(&self) -> Result<Option<ScrapeJob>> {
let mut tx = self.db_pool.begin().await?;
let job = sqlx::query_as::<_, ScrapeJob>(
"SELECT * FROM scrape_jobs WHERE locked_at IS NULL AND execute_at <= NOW() ORDER BY priority DESC, execute_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED"
)
.fetch_optional(&mut *tx)
.await?;
if let Some(ref job) = job {
sqlx::query("UPDATE scrape_jobs SET locked_at = NOW() WHERE id = $1")
.bind(job.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(job)
scrape_jobs::fetch_and_lock_job(&self.db_pool).await
}
async fn process_job(&self, job: ScrapeJob) -> Result<(), JobError> {
@@ -112,7 +96,7 @@ impl Worker {
let job_impl = job_type.boxed();
// Create span with job context
let span = tracing::debug_span!(
let span = tracing::info_span!(
"process_job",
job_id = job.id,
job_type = job_impl.description()
@@ -139,34 +123,15 @@ impl Worker {
}
async fn delete_job(&self, job_id: i32) -> Result<()> {
sqlx::query("DELETE FROM scrape_jobs WHERE id = $1")
.bind(job_id)
.execute(&self.db_pool)
.await?;
Ok(())
scrape_jobs::delete_job(job_id, &self.db_pool).await
}
async fn unlock_job(&self, job_id: i32) -> Result<()> {
sqlx::query("UPDATE scrape_jobs SET locked_at = NULL WHERE id = $1")
.bind(job_id)
.execute(&self.db_pool)
.await?;
Ok(())
scrape_jobs::unlock_job(job_id, &self.db_pool).await
}
async fn unlock_and_increment_retry(&self, job_id: i32, max_retries: i32) -> Result<bool> {
let result = sqlx::query_scalar::<_, Option<i32>>(
"UPDATE scrape_jobs
SET locked_at = NULL, retry_count = retry_count + 1
WHERE id = $1
RETURNING CASE WHEN retry_count + 1 < $2 THEN retry_count + 1 ELSE NULL END",
)
.bind(job_id)
.bind(max_retries)
.fetch_one(&self.db_pool)
.await?;
Ok(result.is_some())
scrape_jobs::unlock_and_increment_retry(job_id, max_retries, &self.db_pool).await
}
/// Handle shutdown signal received during job processing
@@ -269,7 +234,7 @@ impl Worker {
// Atomically unlock and increment retry count, checking if retry is allowed
match self.unlock_and_increment_retry(job_id, max_retries).await {
Ok(can_retry) if can_retry => {
info!(
debug!(
worker_id = self.id,
job_id,
retry_attempt = next_attempt,
+7
View File
@@ -2,6 +2,7 @@ use super::Service;
use crate::bot::{Data, get_commands};
use crate::config::Config;
use crate::state::AppState;
use crate::status::{ServiceStatus, ServiceStatusRegistry};
use num_format::{Locale, ToFormattedString};
use serenity::Client;
use serenity::all::{ActivityData, ClientBuilder, GatewayIntents};
@@ -17,6 +18,7 @@ pub struct BotService {
shard_manager: Arc<serenity::gateway::ShardManager>,
status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
status_shutdown_tx: Option<broadcast::Sender<()>>,
service_statuses: ServiceStatusRegistry,
}
impl BotService {
@@ -98,6 +100,8 @@ impl BotService {
);
*status_task_handle.lock().await = Some(handle);
app_state.service_statuses.set("bot", ServiceStatus::Active);
Ok(Data { app_state })
})
})
@@ -186,6 +190,7 @@ impl BotService {
client: Client,
status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
status_shutdown_tx: broadcast::Sender<()>,
service_statuses: ServiceStatusRegistry,
) -> Self {
let shard_manager = client.shard_manager.clone();
@@ -194,6 +199,7 @@ impl BotService {
shard_manager,
status_task_handle,
status_shutdown_tx: Some(status_shutdown_tx),
service_statuses,
}
}
}
@@ -218,6 +224,7 @@ impl Service for BotService {
}
async fn shutdown(&mut self) -> Result<(), anyhow::Error> {
self.service_statuses.set("bot", ServiceStatus::Disabled);
// Signal status update task to stop
if let Some(status_shutdown_tx) = self.status_shutdown_tx.take() {
let _ = status_shutdown_tx.send(());
+44 -3
View File
@@ -1,4 +1,6 @@
use super::Service;
use crate::state::AppState;
use crate::status::ServiceStatus;
use crate::web::create_router;
use std::net::SocketAddr;
use tokio::net::TcpListener;
@@ -8,16 +10,47 @@ use tracing::{info, trace, warn};
/// Web server service implementation
pub struct WebService {
port: u16,
app_state: AppState,
shutdown_tx: Option<broadcast::Sender<()>>,
}
impl WebService {
pub fn new(port: u16) -> Self {
pub fn new(port: u16, app_state: AppState) -> Self {
Self {
port,
app_state,
shutdown_tx: None,
}
}
/// Periodically pings the database and updates the "database" service status.
async fn db_health_check_loop(
state: AppState,
mut shutdown_rx: broadcast::Receiver<()>,
) {
use std::time::Duration;
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
let status = match sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db_pool)
.await
{
Ok(_) => ServiceStatus::Connected,
Err(e) => {
warn!(error = %e, "DB health check failed");
ServiceStatus::Error
}
};
state.service_statuses.set("database", status);
}
_ = shutdown_rx.recv() => {
break;
}
}
}
}
}
#[async_trait::async_trait]
@@ -28,11 +61,12 @@ impl Service for WebService {
async fn run(&mut self) -> Result<(), anyhow::Error> {
// Create the main router with Banner API routes
let app = create_router();
let app = create_router(self.app_state.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
let listener = TcpListener::bind(addr).await?;
self.app_state.service_statuses.set("web", ServiceStatus::Active);
info!(
service = "web",
address = %addr,
@@ -42,7 +76,14 @@ impl Service for WebService {
// Create internal shutdown channel for axum graceful shutdown
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
self.shutdown_tx = Some(shutdown_tx);
self.shutdown_tx = Some(shutdown_tx.clone());
// Spawn background DB health check
let health_state = self.app_state.clone();
let health_shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
Self::db_health_check_loop(health_state, health_shutdown_rx).await;
});
// Use axum's graceful shutdown with the internal shutdown signal
axum::serve(listener, app)
+3
View File
@@ -2,6 +2,7 @@
use crate::banner::BannerApi;
use crate::banner::Course;
use crate::status::ServiceStatusRegistry;
use anyhow::Result;
use sqlx::PgPool;
use std::sync::Arc;
@@ -10,6 +11,7 @@ use std::sync::Arc;
pub struct AppState {
pub banner_api: Arc<BannerApi>,
pub db_pool: PgPool,
pub service_statuses: ServiceStatusRegistry,
}
impl AppState {
@@ -17,6 +19,7 @@ impl AppState {
Self {
banner_api,
db_pool,
service_statuses: ServiceStatusRegistry::new(),
}
}
+60
View File
@@ -0,0 +1,60 @@
use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use serde::Serialize;
/// Health status of a service.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceStatus {
Starting,
Active,
Connected,
Disabled,
Error,
}
/// A timestamped status entry for a service.
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub status: ServiceStatus,
pub updated_at: Instant,
}
/// Thread-safe registry for services to self-report their health status.
#[derive(Debug, Clone, Default)]
pub struct ServiceStatusRegistry {
inner: Arc<DashMap<String, StatusEntry>>,
}
impl ServiceStatusRegistry {
/// Creates a new empty registry.
pub fn new() -> Self {
Self::default()
}
/// Inserts or updates the status for a named service.
pub fn set(&self, name: &str, status: ServiceStatus) {
self.inner.insert(
name.to_owned(),
StatusEntry {
status,
updated_at: Instant::now(),
},
);
}
/// Returns the current status of a named service, if present.
pub fn get(&self, name: &str) -> Option<ServiceStatus> {
self.inner.get(name).map(|entry| entry.status.clone())
}
/// Returns a snapshot of all service statuses.
pub fn all(&self) -> Vec<(String, ServiceStatus)> {
self.inner
.iter()
.map(|entry| (entry.key().clone(), entry.value().status.clone()))
.collect()
}
}
+2 -2
View File
@@ -4,10 +4,10 @@
//! at compile time using rust-embed.
use dashmap::DashMap;
use once_cell::sync::Lazy;
use rapidhash::v3::rapidhash_v3;
use rust_embed::RustEmbed;
use std::fmt;
use std::sync::LazyLock;
/// Embedded web assets from the dist directory
#[derive(RustEmbed)]
@@ -65,7 +65,7 @@ impl AssetMetadata {
}
/// Global cache for asset metadata to avoid repeated calculations
static ASSET_CACHE: Lazy<DashMap<String, AssetMetadata>> = Lazy::new(DashMap::new);
static ASSET_CACHE: LazyLock<DashMap<String, AssetMetadata>> = LazyLock::new(DashMap::new);
/// Get cached asset metadata for a file path, caching on-demand
/// Returns AssetMetadata containing MIME type and RapidHash hash
+31 -51
View File
@@ -3,7 +3,7 @@
use axum::{
Router,
body::Body,
extract::Request,
extract::{Request, State},
response::{Json, Response},
routing::get,
};
@@ -17,6 +17,9 @@ use http::header;
use serde::Serialize;
use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration};
use crate::state::AppState;
use crate::status::ServiceStatus;
#[cfg(not(feature = "embed-assets"))]
use tower_http::cors::{Any, CorsLayer};
use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer};
@@ -63,11 +66,12 @@ fn set_caching_headers(response: &mut Response, path: &str, etag: &str) {
}
/// Creates the web server router
pub fn create_router() -> Router {
pub fn create_router(app_state: AppState) -> Router {
let api_router = Router::new()
.route("/health", get(health))
.route("/status", get(status))
.route("/metrics", get(metrics));
.route("/metrics", get(metrics))
.with_state(app_state);
let mut router = Router::new().nest("/api", api_router);
@@ -155,7 +159,7 @@ async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap)
// Check if client has a matching ETag (conditional request)
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& metadata.etag_matches(etag.to_str().unwrap())
&& etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{
return StatusCode::NOT_MODIFIED.into_response();
}
@@ -191,7 +195,7 @@ async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap)
// Check if client has a matching ETag for index.html
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& metadata.etag_matches(etag.to_str().unwrap())
&& etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{
return StatusCode::NOT_MODIFIED.into_response();
}
@@ -217,70 +221,46 @@ async fn health() -> Json<Value> {
}))
}
#[derive(Serialize)]
enum Status {
Disabled,
Connected,
Active,
Healthy,
Error,
}
#[derive(Serialize)]
struct ServiceInfo {
name: String,
status: Status,
status: ServiceStatus,
}
#[derive(Serialize)]
struct StatusResponse {
status: Status,
status: ServiceStatus,
version: String,
commit: String,
services: BTreeMap<String, ServiceInfo>,
}
/// Status endpoint showing bot and system status
async fn status() -> Json<StatusResponse> {
async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
let mut services = BTreeMap::new();
// Bot service status - hardcoded as disabled for now
services.insert(
"bot".to_string(),
ServiceInfo {
name: "Bot".to_string(),
status: Status::Disabled,
},
);
for (name, svc_status) in state.service_statuses.all() {
services.insert(
name.clone(),
ServiceInfo {
name,
status: svc_status,
},
);
}
// Banner API status - always connected for now
services.insert(
"banner".to_string(),
ServiceInfo {
name: "Banner".to_string(),
status: Status::Connected,
},
);
// Discord status - hardcoded as disabled for now
services.insert(
"discord".to_string(),
ServiceInfo {
name: "Discord".to_string(),
status: Status::Disabled,
},
);
let overall_status = if services.values().any(|s| matches!(s.status, Status::Error)) {
Status::Error
} else if services
.values()
.all(|s| matches!(s.status, Status::Active | Status::Connected))
let overall_status = if services.values().any(|s| matches!(s.status, ServiceStatus::Error)) {
ServiceStatus::Error
} else if !services.is_empty()
&& services
.values()
.all(|s| matches!(s.status, ServiceStatus::Active | ServiceStatus::Connected))
{
Status::Active
ServiceStatus::Active
} else if services.is_empty() {
ServiceStatus::Disabled
} else {
// If we have any Disabled services but no errors, show as Healthy
Status::Healthy
ServiceStatus::Active
};
Json(StatusResponse {
+212
View File
@@ -0,0 +1,212 @@
mod helpers;
use banner::data::batch::batch_upsert_courses;
use sqlx::PgPool;
#[sqlx::test]
async fn test_batch_upsert_empty_slice(pool: PgPool) {
batch_upsert_courses(&[], &pool).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0);
}
#[sqlx::test]
async fn test_batch_upsert_inserts_new_courses(pool: PgPool) {
let courses = vec![
helpers::make_course("10001", "202510", "CS", "1083", "Intro to CS", 25, 30, 0, 5),
helpers::make_course(
"10002",
"202510",
"MAT",
"1214",
"Calculus I",
40,
45,
3,
10,
),
];
batch_upsert_courses(&courses, &pool).await.unwrap();
let rows: Vec<(String, String, String, String, i32, i32, i32, i32)> = sqlx::query_as(
"SELECT crn, subject, course_number, title, enrollment, max_enrollment, wait_count, wait_capacity
FROM courses ORDER BY crn",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 2);
let (crn, subject, course_number, title, enrollment, max_enrollment, wait_count, wait_capacity) =
&rows[0];
assert_eq!(crn, "10001");
assert_eq!(subject, "CS");
assert_eq!(course_number, "1083");
assert_eq!(title, "Intro to CS");
assert_eq!(*enrollment, 25);
assert_eq!(*max_enrollment, 30);
assert_eq!(*wait_count, 0);
assert_eq!(*wait_capacity, 5);
let (crn, subject, ..) = &rows[1];
assert_eq!(crn, "10002");
assert_eq!(subject, "MAT");
}
#[sqlx::test]
async fn test_batch_upsert_updates_existing(pool: PgPool) {
let initial = vec![helpers::make_course(
"20001",
"202510",
"CS",
"3443",
"App Programming",
10,
35,
0,
5,
)];
batch_upsert_courses(&initial, &pool).await.unwrap();
// Upsert the same CRN+term with updated enrollment
let updated = vec![helpers::make_course(
"20001",
"202510",
"CS",
"3443",
"App Programming",
30,
35,
2,
5,
)];
batch_upsert_courses(&updated, &pool).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 1, "upsert should not create a duplicate row");
let (enrollment, wait_count): (i32, i32) =
sqlx::query_as("SELECT enrollment, wait_count FROM courses WHERE crn = '20001'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 30);
assert_eq!(wait_count, 2);
}
#[sqlx::test]
async fn test_batch_upsert_mixed_insert_and_update(pool: PgPool) {
let initial = vec![
helpers::make_course("30001", "202510", "CS", "1083", "Intro to CS", 10, 30, 0, 5),
helpers::make_course(
"30002",
"202510",
"CS",
"2073",
"Computer Architecture",
20,
30,
0,
5,
),
];
batch_upsert_courses(&initial, &pool).await.unwrap();
// Update both existing courses and add a new one
let mixed = vec![
helpers::make_course("30001", "202510", "CS", "1083", "Intro to CS", 15, 30, 1, 5),
helpers::make_course(
"30002",
"202510",
"CS",
"2073",
"Computer Architecture",
25,
30,
0,
5,
),
helpers::make_course(
"30003",
"202510",
"MAT",
"1214",
"Calculus I",
40,
45,
3,
10,
),
];
batch_upsert_courses(&mixed, &pool).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 3, "should have 2 updated + 1 new = 3 total rows");
// Verify updated values
let (enrollment,): (i32,) =
sqlx::query_as("SELECT enrollment FROM courses WHERE crn = '30001'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 15);
let (enrollment,): (i32,) =
sqlx::query_as("SELECT enrollment FROM courses WHERE crn = '30002'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 25);
// Verify new row
let (subject,): (String,) = sqlx::query_as("SELECT subject FROM courses WHERE crn = '30003'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(subject, "MAT");
}
#[sqlx::test]
async fn test_batch_upsert_unique_constraint_crn_term(pool: PgPool) {
// Same CRN, different term codes → should produce two separate rows
let courses = vec![
helpers::make_course("40001", "202510", "CS", "1083", "Intro to CS", 25, 30, 0, 5),
helpers::make_course("40001", "202520", "CS", "1083", "Intro to CS", 10, 30, 0, 5),
];
batch_upsert_courses(&courses, &pool).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses WHERE crn = '40001'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
count.0, 2,
"same CRN with different term codes should be separate rows"
);
let rows: Vec<(String, i32)> = sqlx::query_as(
"SELECT term_code, enrollment FROM courses WHERE crn = '40001' ORDER BY term_code",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows[0].0, "202510");
assert_eq!(rows[0].1, 25);
assert_eq!(rows[1].0, "202520");
assert_eq!(rows[1].1, 10);
}
+435
View File
@@ -0,0 +1,435 @@
mod helpers;
use banner::data::models::{ScrapePriority, TargetType};
use banner::data::scrape_jobs;
use serde_json::json;
use sqlx::PgPool;
// ── fetch_and_lock_job ──────────────────────────────────────────────
#[sqlx::test]
async fn fetch_and_lock_empty_queue(pool: PgPool) {
let result = scrape_jobs::fetch_and_lock_job(&pool).await.unwrap();
assert!(result.is_none());
}
#[sqlx::test]
async fn fetch_and_lock_returns_job_and_sets_locked_at(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
false,
0,
3,
)
.await;
let job = scrape_jobs::fetch_and_lock_job(&pool)
.await
.unwrap()
.expect("should return a job");
assert_eq!(job.id, id);
assert!(matches!(job.target_type, TargetType::Subject));
assert_eq!(job.target_payload, json!({"subject": "CS"}));
// Verify locked_at was set in the database
let (locked_at,): (Option<chrono::DateTime<chrono::Utc>>,) =
sqlx::query_as("SELECT locked_at FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert!(locked_at.is_some(), "locked_at should be set after fetch");
}
#[sqlx::test]
async fn fetch_and_lock_skips_locked_jobs(pool: PgPool) {
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
true, // locked
0,
3,
)
.await;
let result = scrape_jobs::fetch_and_lock_job(&pool).await.unwrap();
assert!(result.is_none(), "locked jobs should be skipped");
}
#[sqlx::test]
async fn fetch_and_lock_skips_future_execute_at(pool: PgPool) {
// Insert a job with execute_at in the future via raw SQL
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
VALUES ('Subject', '{\"subject\": \"CS\"}', 'Medium', NOW() + INTERVAL '1 hour')",
)
.execute(&pool)
.await
.unwrap();
let result = scrape_jobs::fetch_and_lock_job(&pool).await.unwrap();
assert!(result.is_none(), "future execute_at jobs should be skipped");
}
#[sqlx::test]
async fn fetch_and_lock_priority_desc_ordering(pool: PgPool) {
// Insert low priority first, then critical
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "LOW"}),
ScrapePriority::Low,
false,
0,
3,
)
.await;
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CRIT"}),
ScrapePriority::Critical,
false,
0,
3,
)
.await;
let job = scrape_jobs::fetch_and_lock_job(&pool)
.await
.unwrap()
.expect("should return a job");
assert_eq!(
job.target_payload,
json!({"subject": "CRIT"}),
"Critical priority should be fetched before Low"
);
}
#[sqlx::test]
async fn fetch_and_lock_execute_at_asc_ordering(pool: PgPool) {
// Insert an older job and a newer job, both same priority
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
VALUES ('Subject', '{\"subject\": \"OLDER\"}', 'Medium', NOW() - INTERVAL '2 hours')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
VALUES ('Subject', '{\"subject\": \"NEWER\"}', 'Medium', NOW() - INTERVAL '1 hour')",
)
.execute(&pool)
.await
.unwrap();
let job = scrape_jobs::fetch_and_lock_job(&pool)
.await
.unwrap()
.expect("should return a job");
assert_eq!(
job.target_payload,
json!({"subject": "OLDER"}),
"Older execute_at should be fetched first"
);
}
// ── delete_job ──────────────────────────────────────────────────────
#[sqlx::test]
async fn delete_job_removes_row(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::SingleCrn,
json!({"crn": "12345"}),
ScrapePriority::High,
false,
0,
3,
)
.await;
scrape_jobs::delete_job(id, &pool).await.unwrap();
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0, "row should be deleted");
}
#[sqlx::test]
async fn delete_job_nonexistent_id_no_error(pool: PgPool) {
// Deleting a non-existent ID should not error
scrape_jobs::delete_job(999_999, &pool).await.unwrap();
}
// ── unlock_job ──────────────────────────────────────────────────────
#[sqlx::test]
async fn unlock_job_clears_locked_at(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::CrnList,
json!({"crns": [1, 2, 3]}),
ScrapePriority::Medium,
true, // locked
0,
3,
)
.await;
scrape_jobs::unlock_job(id, &pool).await.unwrap();
let (locked_at,): (Option<chrono::DateTime<chrono::Utc>>,) =
sqlx::query_as("SELECT locked_at FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert!(locked_at.is_none(), "locked_at should be cleared");
}
// ── unlock_and_increment_retry ──────────────────────────────────────
#[sqlx::test]
async fn unlock_and_increment_retry_has_retries_remaining(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
true,
0, // retry_count
3, // max_retries
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(has_retries, "should have retries remaining (0→1, max=3)");
// Verify state in DB
let (retry_count, locked_at): (i32, Option<chrono::DateTime<chrono::Utc>>) =
sqlx::query_as("SELECT retry_count, locked_at FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(retry_count, 1);
assert!(locked_at.is_none(), "should be unlocked");
}
#[sqlx::test]
async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
true,
2, // retry_count
3, // max_retries
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(
!has_retries,
"should NOT have retries remaining (2→3, max=3)"
);
let (retry_count,): (i32,) =
sqlx::query_as("SELECT retry_count FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(retry_count, 3);
}
#[sqlx::test]
async fn unlock_and_increment_retry_already_exceeded(pool: PgPool) {
let id = helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
true,
5, // retry_count already past max
3, // max_retries
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(
!has_retries,
"should NOT have retries remaining (5→6, max=3)"
);
let (retry_count,): (i32,) =
sqlx::query_as("SELECT retry_count FROM scrape_jobs WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(retry_count, 6);
}
// ── find_existing_job_payloads ──────────────────────────────────────
#[sqlx::test]
async fn find_existing_payloads_returns_matching(pool: PgPool) {
let payload_a = json!({"subject": "CS"});
let payload_b = json!({"subject": "MAT"});
let payload_c = json!({"subject": "ENG"});
// Insert A and B as Subject jobs
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
payload_a.clone(),
ScrapePriority::Medium,
false,
0,
3,
)
.await;
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
payload_b.clone(),
ScrapePriority::Medium,
false,
0,
3,
)
.await;
// Insert C as a different target type
helpers::insert_scrape_job(
&pool,
TargetType::SingleCrn,
payload_c.clone(),
ScrapePriority::Medium,
false,
0,
3,
)
.await;
let candidates = vec![payload_a.clone(), payload_b.clone(), payload_c.clone()];
let existing = scrape_jobs::find_existing_job_payloads(TargetType::Subject, &candidates, &pool)
.await
.unwrap();
assert!(existing.contains(&payload_a.to_string()));
assert!(existing.contains(&payload_b.to_string()));
// payload_c is SingleCrn, not Subject — should not match
assert!(!existing.contains(&payload_c.to_string()));
}
#[sqlx::test]
async fn find_existing_payloads_ignores_locked(pool: PgPool) {
let payload = json!({"subject": "CS"});
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
payload.clone(),
ScrapePriority::Medium,
true, // locked
0,
3,
)
.await;
let candidates = vec![payload.clone()];
let existing = scrape_jobs::find_existing_job_payloads(TargetType::Subject, &candidates, &pool)
.await
.unwrap();
assert!(existing.is_empty(), "locked jobs should be ignored");
}
#[sqlx::test]
async fn find_existing_payloads_empty_candidates(pool: PgPool) {
// Insert a job so the table isn't empty
helpers::insert_scrape_job(
&pool,
TargetType::Subject,
json!({"subject": "CS"}),
ScrapePriority::Medium,
false,
0,
3,
)
.await;
let existing = scrape_jobs::find_existing_job_payloads(TargetType::Subject, &[], &pool)
.await
.unwrap();
assert!(
existing.is_empty(),
"empty candidates should return empty result"
);
}
// ── batch_insert_jobs ───────────────────────────────────────────────
#[sqlx::test]
async fn batch_insert_jobs_inserts_multiple(pool: PgPool) {
let jobs = vec![
(
json!({"subject": "CS"}),
TargetType::Subject,
ScrapePriority::High,
),
(
json!({"subject": "MAT"}),
TargetType::Subject,
ScrapePriority::Medium,
),
(
json!({"crn": "12345"}),
TargetType::SingleCrn,
ScrapePriority::Low,
),
];
scrape_jobs::batch_insert_jobs(&jobs, &pool).await.unwrap();
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 3);
}
#[sqlx::test]
async fn batch_insert_jobs_empty_slice(pool: PgPool) {
scrape_jobs::batch_insert_jobs(&[], &pool).await.unwrap();
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0);
}
+88
View File
@@ -0,0 +1,88 @@
use banner::banner::Course;
use banner::data::models::{ScrapePriority, TargetType};
use chrono::Utc;
use sqlx::PgPool;
/// Build a test `Course` (Banner API model) with sensible defaults.
///
/// Only the fields used by `batch_upsert_courses` need meaningful values;
/// the rest are filled with harmless placeholders.
pub fn make_course(
crn: &str,
term: &str,
subject: &str,
course_number: &str,
title: &str,
enrollment: i32,
max_enrollment: i32,
wait_count: i32,
wait_capacity: i32,
) -> Course {
Course {
id: 0,
term: term.to_owned(),
term_desc: String::new(),
course_reference_number: crn.to_owned(),
part_of_term: "1".to_owned(),
course_number: course_number.to_owned(),
subject: subject.to_owned(),
subject_description: subject.to_owned(),
sequence_number: "001".to_owned(),
campus_description: "Main Campus".to_owned(),
schedule_type_description: "Lecture".to_owned(),
course_title: title.to_owned(),
credit_hours: Some(3),
maximum_enrollment: max_enrollment,
enrollment,
seats_available: max_enrollment - enrollment,
wait_capacity,
wait_count,
cross_list: None,
cross_list_capacity: None,
cross_list_count: None,
cross_list_available: None,
credit_hour_high: None,
credit_hour_low: None,
credit_hour_indicator: None,
open_section: enrollment < max_enrollment,
link_identifier: None,
is_section_linked: false,
subject_course: format!("{subject}{course_number}"),
reserved_seat_summary: None,
instructional_method: "FF".to_owned(),
instructional_method_description: "Face to Face".to_owned(),
section_attributes: vec![],
faculty: vec![],
meetings_faculty: vec![],
}
}
/// Insert a scrape job row directly via SQL, returning the generated ID.
pub async fn insert_scrape_job(
pool: &PgPool,
target_type: TargetType,
payload: serde_json::Value,
priority: ScrapePriority,
locked: bool,
retry_count: i32,
max_retries: i32,
) -> i32 {
let locked_at = if locked { Some(Utc::now()) } else { None };
let (id,): (i32,) = sqlx::query_as(
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at, locked_at, retry_count, max_retries)
VALUES ($1, $2, $3, NOW(), $4, $5, $6)
RETURNING id",
)
.bind(target_type)
.bind(payload)
.bind(priority)
.bind(locked_at)
.bind(retry_count)
.bind(max_retries)
.fetch_one(pool)
.await
.expect("insert_scrape_job failed");
id
}
+20
View File
@@ -20,6 +20,26 @@
animation: pulse 2s ease-in-out infinite;
}
/* Theme toggle button */
.theme-toggle {
cursor: pointer;
background-color: transparent;
border: none;
margin: 4px;
padding: 7px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-11);
transition: background-color 0.2s, color 0.2s;
transform: scale(1.25);
}
.theme-toggle:hover {
background-color: var(--gray-4);
}
/* Screen reader only text */
.sr-only {
position: absolute;
+3 -27
View File
@@ -1,6 +1,6 @@
import { useTheme } from "next-themes";
import { Button } from "@radix-ui/themes";
import { Sun, Moon, Monitor } from "lucide-react";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useMemo } from "react";
export function ThemeToggle() {
@@ -28,31 +28,7 @@ export function ThemeToggle() {
}, [nextTheme]);
return (
<Button
variant="ghost"
size="3"
onClick={() => setTheme(nextTheme)}
style={{
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
margin: "4px",
padding: "7px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--gray-11)",
transition: "background-color 0.2s, color 0.2s",
transform: "scale(1.25)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--gray-4)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<Button variant="ghost" size="3" onClick={() => setTheme(nextTheme)} className="theme-toggle">
{icon}
<span className="sr-only">Toggle theme</span>
</Button>
+8 -6
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BannerApiClient } from "./api";
// Mock fetch
@@ -31,11 +31,13 @@ describe("BannerApiClient", () => {
it("should fetch status data", async () => {
const mockStatus = {
status: "operational",
bot: { status: "running", uptime: "1h" },
cache: { status: "connected", courses: "100", subjects: "50" },
banner_api: { status: "connected" },
timestamp: "2024-01-01T00:00:00Z",
status: "active" as const,
version: "0.3.4",
commit: "abc1234",
services: {
web: { name: "web", status: "active" as const },
database: { name: "database", status: "connected" as const },
},
};
vi.mocked(fetch).mockResolvedValueOnce({
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HealthResponse {
timestamp: string;
}
export type Status = "Disabled" | "Connected" | "Active" | "Healthy" | "Error";
export type Status = "starting" | "active" | "connected" | "disabled" | "error";
export interface ServiceInfo {
name: string;
+41 -21
View File
@@ -1,21 +1,21 @@
import { Card, Flex, Skeleton, Text, Tooltip } from "@radix-ui/themes";
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { client, type StatusResponse, type Status } from "../lib/api";
import { Card, Flex, Text, Tooltip, Skeleton } from "@radix-ui/themes";
import {
CheckCircle,
XCircle,
Clock,
Activity,
Bot,
CheckCircle,
Circle,
Clock,
Globe,
Hourglass,
Activity,
MessageCircle,
Circle,
WifiOff,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import TimeAgo from "react-timeago";
import { ThemeToggle } from "../components/ThemeToggle";
import { type Status, type StatusResponse, client } from "../lib/api";
import "../App.css";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
@@ -37,6 +37,9 @@ const SERVICE_ICONS: Record<string, typeof Bot> = {
bot: Bot,
banner: Globe,
discord: MessageCircle,
database: Activity,
web: Globe,
scraper: Clock,
};
interface ResponseTiming {
@@ -80,11 +83,11 @@ const formatNumber = (num: number): string => {
const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const statusMap: Record<Status | "Unreachable", StatusIcon> = {
Active: { icon: CheckCircle, color: "green" },
Connected: { icon: CheckCircle, color: "green" },
Healthy: { icon: CheckCircle, color: "green" },
Disabled: { icon: Circle, color: "gray" },
Error: { icon: XCircle, color: "red" },
active: { icon: CheckCircle, color: "green" },
connected: { icon: CheckCircle, color: "green" },
starting: { icon: Hourglass, color: "orange" },
disabled: { icon: Circle, color: "gray" },
error: { icon: XCircle, color: "red" },
Unreachable: { icon: WifiOff, color: "red" },
};
@@ -93,9 +96,9 @@ const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const getOverallHealth = (state: StatusState): Status | "Unreachable" => {
if (state.mode === "timeout") return "Unreachable";
if (state.mode === "error") return "Error";
if (state.mode === "error") return "error";
if (state.mode === "response") return state.status.status;
return "Error";
return "error";
};
const getServices = (state: StatusState): Service[] => {
@@ -116,8 +119,8 @@ const StatusDisplay = ({ status }: { status: Status | "Unreachable" }) => {
<Text
size="2"
style={{
color: status === "Disabled" ? "var(--gray-11)" : undefined,
opacity: status === "Disabled" ? 0.7 : undefined,
color: status === "disabled" ? "var(--gray-11)" : undefined,
opacity: status === "disabled" ? 0.7 : undefined,
}}
>
{status}
@@ -187,20 +190,29 @@ function App() {
const shouldShowLastFetch = hasResponse || hasError || hasTimeout;
useEffect(() => {
let timeoutId: NodeJS.Timeout;
let timeoutId: NodeJS.Timeout | null = null;
let requestTimeoutId: NodeJS.Timeout | null = null;
const fetchData = async () => {
try {
const startTime = Date.now();
// Create a timeout promise
// Create a timeout promise with cleanup tracking
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Request timeout")), REQUEST_TIMEOUT);
requestTimeoutId = setTimeout(() => {
reject(new Error("Request timeout"));
}, REQUEST_TIMEOUT);
});
// Race between the API call and timeout
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
// Clear the timeout if the request succeeded
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const endTime = Date.now();
const responseTime = endTime - startTime;
@@ -211,9 +223,14 @@ function App() {
lastFetch: new Date(),
});
} catch (err) {
// Clear the timeout on error as well
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
requestTimeoutId = null;
}
const errorMessage = err instanceof Error ? err.message : "Failed to fetch data";
// Check if it's a timeout error
if (errorMessage === "Request timeout") {
setState({
mode: "timeout",
@@ -238,6 +255,9 @@ function App() {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
}
};
}, []);