mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 14:23:36 -06:00
Compare commits
4 Commits
992263205c
...
v0.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ce0257fdc | |||
| fa2fc45aa9 | |||
| 7cc8267c2e | |||
| 1733ee5f86 |
Generated
-22
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -195,3 +195,6 @@ test-smoke port="18080":
|
||||
alias b := bun
|
||||
bun *ARGS:
|
||||
cd web && bun {{ ARGS }}
|
||||
|
||||
sql *ARGS:
|
||||
lazysql ${DATABASE_URL}
|
||||
|
||||
+9
-6
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
pub mod batch;
|
||||
pub mod models;
|
||||
pub mod scrape_jobs;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user