i18n+filtering fork - fluent-templates v2
at main 691 lines 26 kB view raw
1// Middleware for extracting filter parameters from HTTP requests 2// 3// Parses query parameters and form data to construct EventFilterCriteria, 4// handling validation and normalization of input data. 5 6use axum::{ 7 extract::{Request}, 8 middleware::Next, 9 response::Response, 10}; 11use chrono::{DateTime, Datelike, Utc}; 12use serde::Serialize; 13use std::collections::HashMap; 14use tracing::{debug, info, instrument, warn}; 15 16use crate::filtering::{EventFilterCriteria, EventSortField, LocationFilter, SortOrder}; 17use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 18use crate::http::middleware_timezone::DetectedTimezone; 19 20/// Query parameters for event filtering 21#[derive(Debug, Clone, Serialize)] 22pub struct FilterQueryParams { 23 /// Text search term 24 pub q: Option<String>, 25 26 /// Start date in ISO format 27 pub start_date: Option<String>, 28 29 /// End date in ISO format 30 pub end_date: Option<String>, 31 32 /// Creator DID 33 pub creator: Option<String>, 34 35 /// Event modes (multiple params or comma-separated) 36 pub modes: Vec<String>, 37 38 /// Event statuses (multiple params or comma-separated) 39 pub statuses: Vec<String>, 40 41 /// Date ranges (predefined ranges like "this-month", "today", etc.) 42 pub date_ranges: Vec<String>, 43 44 /// Location latitude 45 pub lat: Option<f64>, 46 47 /// Location longitude 48 pub lng: Option<f64>, 49 50 /// Location radius in kilometers 51 pub radius: Option<f64>, 52 53 /// Location field (alternative to lat/lng) 54 pub location: Option<String>, 55 56 /// Sort field 57 pub sort: Option<String>, 58 59 /// Sort order (asc/desc) 60 pub order: Option<String>, 61 62 /// Page number (1-based, as received from URL parameters) 63 pub page: Option<usize>, 64 65 /// Page size (either 'size' or 'per_page') 66 pub size: Option<usize>, 67 pub per_page: Option<usize>, 68} 69 70impl Default for FilterQueryParams { 71 fn default() -> Self { 72 Self { 73 q: None, 74 start_date: None, 75 end_date: None, 76 creator: None, 77 modes: Vec::new(), 78 statuses: Vec::new(), 79 date_ranges: Vec::new(), 80 lat: None, 81 lng: None, 82 radius: None, 83 location: None, 84 sort: None, 85 order: None, 86 page: None, 87 size: None, 88 per_page: None, 89 } 90 } 91} 92 93/// Extension for storing parsed filter criteria in request 94#[derive(Debug, Clone)] 95pub struct FilterCriteriaExtension { 96 pub criteria: EventFilterCriteria, 97 pub raw_params: FilterQueryParams, 98} 99 100/// Middleware function to extract and parse filter parameters 101#[instrument(skip(request, next))] 102pub async fn extract_filter_params( 103 mut request: Request, 104 next: Next, 105) -> Result<Response, axum::response::Response> { 106 // Extract query string from URI 107 let query_string = request.uri().query().unwrap_or(""); 108 info!("MIDDLEWARE: Processing request with query string: '{}'", query_string); 109 110 // Parse query parameters using custom parser that handles array fields properly 111 let query_params = if query_string.is_empty() { 112 FilterQueryParams::default() 113 } else { 114 match parse_filter_query_params(query_string) { 115 Ok(params) => params, 116 Err(e) => { 117 warn!("Failed to parse query parameters: {}", e); 118 FilterQueryParams::default() 119 } 120 } 121 }; 122 123 // Extract timezone from request extensions (injected by timezone middleware) 124 let detected_timezone = request.extensions().get::<DetectedTimezone>().cloned(); 125 126 // Convert to EventFilterCriteria 127 let criteria = match convert_to_criteria(&query_params, detected_timezone.as_ref()) { 128 Ok(criteria) => criteria, 129 Err(e) => { 130 warn!("Failed to convert query parameters to criteria: {}", e); 131 EventFilterCriteria::new() 132 } 133 }; 134 135 // Store in request extensions 136 request.extensions_mut().insert(FilterCriteriaExtension { 137 criteria, 138 raw_params: query_params, 139 }); 140 141 Ok(next.run(request).await) 142} 143 144/// Custom parser for URL query parameters that properly handles array fields 145fn parse_filter_query_params(query_string: &str) -> Result<FilterQueryParams, String> { 146 info!("PARSING QUERY STRING: {}", query_string); 147 let mut params = HashMap::new(); 148 149 // Parse all parameters into a map of key -> Vec<String> 150 for param in query_string.split('&') { 151 if let Some((key, value)) = param.split_once('=') { 152 let decoded_key = urlencoding::decode(key).map_err(|e| format!("Invalid key encoding: {}", e))?; 153 let decoded_value = urlencoding::decode(value).map_err(|e| format!("Invalid value encoding: {}", e))?; 154 155 params.entry(decoded_key.to_string()) 156 .or_insert_with(Vec::new) 157 .push(decoded_value.to_string()); 158 } 159 } 160 161 // Helper function to get the first value for single-value fields 162 let get_single = |key: &str| -> Option<String> { 163 params.get(key).and_then(|v| v.first().cloned()).filter(|s| !s.is_empty()) 164 }; 165 166 // Helper function to parse numeric values 167 let parse_numeric = |key: &str| -> Option<usize> { 168 get_single(key).and_then(|v| v.parse().ok()) 169 }; 170 171 let parse_float = |key: &str| -> Option<f64> { 172 get_single(key).and_then(|v| v.parse().ok()) 173 }; 174 175 // Helper function to get all values for array fields 176 // This handles both multiple parameters with the same key AND comma-separated values within a single parameter 177 let get_array = |key: &str| -> Vec<String> { 178 params.get(key) 179 .map(|values| { 180 values.iter() 181 .flat_map(|v| v.split(',')) 182 .map(|s| s.trim().to_string()) 183 .filter(|s| !s.is_empty()) 184 .collect() 185 }) 186 .unwrap_or_default() 187 }; 188 189 let result = FilterQueryParams { 190 q: get_single("q"), 191 start_date: get_single("start_date"), 192 end_date: get_single("end_date"), 193 creator: get_single("creator"), 194 modes: get_array("modes"), 195 statuses: get_array("statuses"), 196 date_ranges: get_array("date_ranges"), 197 lat: parse_float("lat"), 198 lng: parse_float("lng"), 199 radius: parse_float("radius"), 200 location: get_single("location"), 201 sort: get_single("sort"), 202 order: get_single("order"), 203 page: parse_numeric("page"), 204 size: parse_numeric("size"), 205 per_page: parse_numeric("per_page"), 206 }; 207 208 info!("PARSED FilterQueryParams: {:?}", result); 209 Ok(result) 210} 211 212/// Convert query parameters to EventFilterCriteria 213fn convert_to_criteria(params: &FilterQueryParams, detected_timezone: Option<&DetectedTimezone>) -> Result<EventFilterCriteria, String> { 214 let mut criteria = EventFilterCriteria::new(); 215 216 // Search term 217 if let Some(ref q) = params.q { 218 if !q.trim().is_empty() { 219 criteria.search_term = Some(q.trim().to_string()); 220 } 221 } 222 223 // Date range 224 if let Some(ref start_str) = params.start_date { 225 criteria.start_date = parse_datetime(start_str) 226 .map_err(|e| format!("Invalid start date: {}", e))?; 227 } 228 229 if let Some(ref end_str) = params.end_date { 230 criteria.end_date = parse_datetime(end_str) 231 .map_err(|e| format!("Invalid end date: {}", e))?; 232 } 233 234 // Handle date_ranges parameter (overrides start_date/end_date if present) 235 if !params.date_ranges.is_empty() { 236 info!("PROCESSING date_ranges: {:?}", params.date_ranges); 237 let (start_date, end_date) = parse_date_ranges(&params.date_ranges)?; 238 info!("PARSED date range: start={:?}, end={:?}", start_date, end_date); 239 if let Some(start) = start_date { 240 criteria.start_date = Some(start); 241 } 242 if let Some(end) = end_date { 243 criteria.end_date = Some(end); 244 } 245 } 246 247 // Set timezone-aware default date range if no dates specified 248 // Default: today to +5 days in user's timezone 249 if criteria.start_date.is_none() && criteria.end_date.is_none() && params.date_ranges.is_empty() { 250 let timezone_str = detected_timezone 251 .map(|tz| tz.timezone.as_str()) 252 .unwrap_or("UTC"); 253 254 info!("No date parameters provided, setting timezone-aware defaults for timezone: {}", timezone_str); 255 256 // Parse timezone 257 match timezone_str.parse::<chrono_tz::Tz>() { 258 Ok(tz) => { 259 let now_in_tz = Utc::now().with_timezone(&tz); 260 let today = now_in_tz.date_naive(); 261 let end_date = today + chrono::Duration::days(5); 262 263 // Convert to UTC DateTime for storage 264 let start_datetime_utc = today.and_hms_opt(0, 0, 0).unwrap().and_local_timezone(tz).unwrap().with_timezone(&Utc); 265 let end_datetime_utc = end_date.and_hms_opt(23, 59, 59).unwrap().and_local_timezone(tz).unwrap().with_timezone(&Utc); 266 267 criteria.start_date = Some(start_datetime_utc); 268 criteria.end_date = Some(end_datetime_utc); 269 270 info!("Set default date range: {} to {} (user timezone: {})", 271 start_datetime_utc.format("%Y-%m-%d %H:%M:%S UTC"), 272 end_datetime_utc.format("%Y-%m-%d %H:%M:%S UTC"), 273 timezone_str); 274 }, 275 Err(_) => { 276 // Fallback to UTC if timezone parsing fails 277 warn!("Failed to parse timezone '{}', using UTC for default dates", timezone_str); 278 let now_utc = Utc::now(); 279 let today = now_utc.date_naive(); 280 let end_date = today + chrono::Duration::days(5); 281 282 criteria.start_date = Some(today.and_hms_opt(0, 0, 0).unwrap().and_utc()); 283 criteria.end_date = Some(end_date.and_hms_opt(23, 59, 59).unwrap().and_utc()); 284 285 info!("Set default date range (UTC fallback): {} to {}", 286 criteria.start_date.unwrap().format("%Y-%m-%d %H:%M:%S UTC"), 287 criteria.end_date.unwrap().format("%Y-%m-%d %H:%M:%S UTC")); 288 } 289 } 290 } 291 292 // Creator 293 if let Some(ref creator) = params.creator { 294 if !creator.trim().is_empty() { 295 criteria.creator_did = Some(creator.trim().to_string()); 296 } 297 } 298 299 // Modes 300 if !params.modes.is_empty() { 301 criteria.modes = parse_modes_from_vec(&params.modes); 302 } 303 304 // Statuses 305 if !params.statuses.is_empty() { 306 criteria.statuses = parse_statuses_from_vec(&params.statuses); 307 } 308 309 // Location 310 if let (Some(lat), Some(lng)) = (params.lat, params.lng) { 311 let radius = params.radius.unwrap_or(10.0); // Default 10km radius 312 313 if lat >= -90.0 && lat <= 90.0 && lng >= -180.0 && lng <= 180.0 && radius > 0.0 { 314 criteria.location = Some(LocationFilter { 315 latitude: lat, 316 longitude: lng, 317 radius_km: radius, 318 }); 319 } else { 320 return Err("Invalid location parameters".to_string()); 321 } 322 } 323 324 // Sorting 325 if let Some(ref sort_str) = params.sort { 326 criteria.sort_by = parse_sort_field(sort_str)?; 327 328 // Extract sort order from combined parameters (e.g., "date_asc" -> Ascending) 329 let sort_lower = sort_str.to_lowercase(); 330 if sort_lower.ends_with("_asc") { 331 criteria.sort_order = SortOrder::Ascending; 332 } else if sort_lower.ends_with("_desc") { 333 criteria.sort_order = SortOrder::Descending; 334 } 335 } 336 337 // Allow explicit order parameter to override combined parameter 338 if let Some(ref order_str) = params.order { 339 criteria.sort_order = parse_sort_order(order_str)?; 340 } 341 342 // Pagination 343 if let Some(page) = params.page { 344 criteria.page = page; 345 } 346 347 // Handle both 'size' and 'per_page' parameters 348 let page_size = params.per_page.or(params.size); 349 if let Some(size) = page_size { 350 if size > 0 && size <= 100 { 351 criteria.page_size = size; 352 } else { 353 return Err("Page size must be between 1 and 100".to_string()); 354 } 355 } 356 357 Ok(criteria) 358} 359 360/// Parse datetime string (ISO 8601 format) 361fn parse_datetime(date_str: &str) -> Result<Option<DateTime<Utc>>, String> { 362 if date_str.trim().is_empty() { 363 return Ok(None); 364 } 365 366 // Try different datetime formats 367 let formats = [ 368 "%Y-%m-%dT%H:%M:%SZ", 369 "%Y-%m-%dT%H:%M:%S%.fZ", 370 "%Y-%m-%d %H:%M:%S", 371 "%Y-%m-%d", 372 ]; 373 374 for format in &formats { 375 if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(date_str, format) { 376 return Ok(Some(naive_dt.and_utc())); 377 } 378 379 if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { 380 return Ok(Some(date.and_hms_opt(0, 0, 0).unwrap().and_utc())); 381 } 382 } 383 384 // Try parsing as RFC 3339 385 match DateTime::parse_from_rfc3339(date_str) { 386 Ok(dt) => Ok(Some(dt.with_timezone(&Utc))), 387 Err(_) => Err(format!("Unable to parse date: {}", date_str)), 388 } 389} 390 391/// Parse modes from comma-separated string 392fn parse_modes(modes_str: &str) -> Vec<Mode> { 393 modes_str 394 .split(',') 395 .map(|s| s.trim()) 396 .filter(|s| !s.is_empty()) 397 .filter_map(|s| { 398 // Handle both full schema identifiers and simple strings 399 let mode_part = if s.contains('#') { 400 // Extract the part after the # for schema identifiers like "community.lexicon.calendar.event#hybrid" 401 s.split('#').nth(1).unwrap_or(s) 402 } else { 403 s 404 }; 405 406 match mode_part.to_lowercase().as_str() { 407 "inperson" | "in-person" | "in_person" => Some(Mode::InPerson), 408 "virtual" => Some(Mode::Virtual), 409 "hybrid" => Some(Mode::Hybrid), 410 _ => None, 411 } 412 }) 413 .collect() 414} 415 416/// Parse modes from vector of strings (handles both individual values and comma-separated) 417fn parse_modes_from_vec(modes_vec: &[String]) -> Vec<Mode> { 418 let mut result = Vec::new(); 419 for mode_str in modes_vec { 420 result.extend(parse_modes(mode_str)); 421 } 422 result 423} 424 425/// Parse statuses from comma-separated string 426fn parse_statuses(statuses_str: &str) -> Vec<Status> { 427 statuses_str 428 .split(',') 429 .map(|s| s.trim()) 430 .filter(|s| !s.is_empty()) 431 .filter_map(|s| { 432 // Handle both full schema identifiers and simple strings 433 let status_part = if s.contains('#') { 434 // Extract the part after the # for schema identifiers like "community.lexicon.calendar.event#cancelled" 435 s.split('#').nth(1).unwrap_or(s) 436 } else { 437 s 438 }; 439 440 match status_part.to_lowercase().as_str() { 441 "scheduled" => Some(Status::Scheduled), 442 "rescheduled" => Some(Status::Rescheduled), 443 "cancelled" | "canceled" => Some(Status::Cancelled), 444 "postponed" => Some(Status::Postponed), 445 "planned" => Some(Status::Planned), 446 _ => None, 447 } 448 }) 449 .collect() 450} 451 452/// Parse statuses from vector of strings (handles both individual values and comma-separated) 453fn parse_statuses_from_vec(statuses_vec: &[String]) -> Vec<Status> { 454 let mut result = Vec::new(); 455 for status_str in statuses_vec { 456 result.extend(parse_statuses(status_str)); 457 } 458 result 459} 460 461/// Parse sort field from string 462fn parse_sort_field(sort_str: &str) -> Result<EventSortField, String> { 463 let sort_lower = sort_str.to_lowercase(); 464 465 // Handle combined field_order format (e.g., "date_asc", "name_desc") 466 let field_part = if sort_lower.ends_with("_asc") || sort_lower.ends_with("_desc") { 467 let underscore_pos = sort_lower.rfind('_').unwrap(); 468 &sort_lower[..underscore_pos] 469 } else { 470 &sort_lower 471 }; 472 473 match field_part { 474 "start_time" | "starts_at" | "date" => Ok(EventSortField::StartTime), 475 "created_at" | "created" | "updated_at" | "updated" => Ok(EventSortField::UpdatedAt), 476 "name" | "title" => Ok(EventSortField::Name), 477 "popularity" | "rsvp_count" => Ok(EventSortField::PopularityRsvp), 478 _ => Err(format!("Unknown sort field: {}", sort_str)), 479 } 480} 481 482/// Parse sort order from string 483fn parse_sort_order(order_str: &str) -> Result<SortOrder, String> { 484 match order_str.to_lowercase().as_str() { 485 "asc" | "ascending" => Ok(SortOrder::Ascending), 486 "desc" | "descending" => Ok(SortOrder::Descending), 487 _ => Err(format!("Unknown sort order: {}", order_str)), 488 } 489} 490 491 492/// Parse date ranges from vector of strings and return combined start/end dates 493fn parse_date_ranges(date_ranges: &[String]) -> Result<(Option<DateTime<Utc>>, Option<DateTime<Utc>>), String> { 494 if date_ranges.is_empty() { 495 return Ok((None, None)); 496 } 497 498 let now = chrono::Utc::now(); 499 let mut earliest_start: Option<DateTime<Utc>> = None; 500 let mut latest_end: Option<DateTime<Utc>> = None; 501 502 for range_key in date_ranges { 503 let range_key = range_key.trim(); 504 if range_key.is_empty() { 505 continue; 506 } 507 508 let (start_date, end_date) = match range_key { 509 "today" => { 510 let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 511 let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); 512 (start, end) 513 } 514 "this_week" | "this-week" => { 515 let days_since_monday = now.weekday().num_days_from_monday(); 516 let start = (now - chrono::Duration::days(days_since_monday as i64)) 517 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 518 let end = start + chrono::Duration::days(6); 519 (start, end) 520 } 521 "this_month" | "this-month" => { 522 let start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc(); 523 let end = if now.month() == 12 { 524 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() 525 } else { 526 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() 527 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 528 (start, end) 529 } 530 "next_week" | "next-week" => { 531 // Next week starts on the Monday after this week ends 532 let days_since_monday = now.weekday().num_days_from_monday(); 533 let days_until_next_monday = 7 - days_since_monday; 534 let start = (now + chrono::Duration::days(days_until_next_monday as i64)) 535 .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); 536 let end = start + chrono::Duration::days(6); 537 let end = end.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); 538 539 debug!("Next week calculation: now={}, days_since_monday={}, days_until_next_monday={}, start={}, end={}", 540 now, days_since_monday, days_until_next_monday, start, end); 541 542 (start, end) 543 } 544 "next_month" | "next-month" => { 545 let start = if now.month() == 12 { 546 chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() 547 } else { 548 chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() 549 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 550 let end = if start.month() == 12 { 551 chrono::NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap() 552 } else { 553 chrono::NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1).unwrap() 554 }.and_hms_opt(0, 0, 0).unwrap().and_utc(); 555 (start, end) 556 } 557 _ => return Err(format!("Unknown date range: {}", range_key)), 558 }; 559 560 // Update the overall date range 561 if earliest_start.is_none() || start_date < earliest_start.unwrap() { 562 earliest_start = Some(start_date); 563 } 564 if latest_end.is_none() || end_date > latest_end.unwrap() { 565 latest_end = Some(end_date); 566 } 567 } 568 569 Ok((earliest_start, latest_end)) 570} 571 572#[cfg(test)] 573mod tests { 574 use super::*; 575 576 #[test] 577 fn test_parse_datetime() { 578 assert!(parse_datetime("2024-01-01").unwrap().is_some()); 579 assert!(parse_datetime("2024-01-01T10:00:00Z").unwrap().is_some()); 580 assert!(parse_datetime("").unwrap().is_none()); 581 assert!(parse_datetime("invalid").is_err()); 582 } 583 584 #[test] 585 fn test_parse_modes() { 586 // Test simple mode strings 587 let modes = parse_modes("inperson,virtual,hybrid"); 588 assert_eq!(modes, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); 589 590 let with_variations = parse_modes("in-person, virtual, hybrid"); 591 assert_eq!(with_variations, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); 592 593 // Test full schema identifiers (as sent by the frontend) 594 let schema_modes = parse_modes("community.lexicon.calendar.event#hybrid,community.lexicon.calendar.event#virtual,community.lexicon.calendar.event#inperson"); 595 assert_eq!(schema_modes, vec![Mode::Hybrid, Mode::Virtual, Mode::InPerson]); 596 597 // Test mixed formats 598 let mixed = parse_modes("hybrid,community.lexicon.calendar.event#virtual"); 599 assert_eq!(mixed, vec![Mode::Hybrid, Mode::Virtual]); 600 601 let empty = parse_modes(""); 602 assert!(empty.is_empty()); 603 } 604 605 #[test] 606 fn test_parse_statuses() { 607 let statuses = parse_statuses("scheduled,cancelled,planned"); 608 assert_eq!(statuses, vec![Status::Scheduled, Status::Cancelled, Status::Planned]); 609 610 let with_variations = parse_statuses("canceled, postponed"); 611 assert_eq!(with_variations, vec![Status::Cancelled, Status::Postponed]); 612 613 // Test full schema identifiers (as sent by the frontend) 614 let schema_statuses = parse_statuses("community.lexicon.calendar.event#scheduled,community.lexicon.calendar.event#cancelled,community.lexicon.calendar.event#planned"); 615 assert_eq!(schema_statuses, vec![Status::Scheduled, Status::Cancelled, Status::Planned]); 616 617 // Test mixed formats 618 let mixed = parse_statuses("cancelled,community.lexicon.calendar.event#scheduled"); 619 assert_eq!(mixed, vec![Status::Cancelled, Status::Scheduled]); 620 621 let empty = parse_statuses(""); 622 assert!(empty.is_empty()); 623 } 624 625 #[test] 626 fn test_parse_sort_field() { 627 // Test basic field names 628 assert!(matches!(parse_sort_field("name"), Ok(EventSortField::Name))); 629 assert!(matches!(parse_sort_field("date"), Ok(EventSortField::StartTime))); 630 631 // Test combined field_order parameters 632 assert!(matches!(parse_sort_field("date_asc"), Ok(EventSortField::StartTime))); 633 assert!(matches!(parse_sort_field("date_desc"), Ok(EventSortField::StartTime))); 634 assert!(matches!(parse_sort_field("name_asc"), Ok(EventSortField::Name))); 635 assert!(matches!(parse_sort_field("popularity_desc"), Ok(EventSortField::PopularityRsvp))); 636 637 // Test case insensitivity 638 assert!(matches!(parse_sort_field("DATE_ASC"), Ok(EventSortField::StartTime))); 639 640 assert!(parse_sort_field("invalid").is_err()); 641 assert!(parse_sort_field("invalid_asc").is_err()); 642 } 643 644 #[test] 645 fn test_parse_date_ranges() { 646 // Test single date range 647 let single = parse_date_ranges(&["this-month".to_string()]).unwrap(); 648 assert!(single.0.is_some()); // start_date should be set 649 assert!(single.1.is_some()); // end_date should be set 650 651 // Test empty ranges 652 let empty = parse_date_ranges(&[]).unwrap(); 653 assert!(empty.0.is_none()); 654 assert!(empty.1.is_none()); 655 656 // Test invalid range 657 let invalid = parse_date_ranges(&["invalid-range".to_string()]); 658 assert!(invalid.is_err()); 659 660 // Test multiple date ranges 661 let multiple = parse_date_ranges(&["today".to_string(), "this-week".to_string()]).unwrap(); 662 assert!(multiple.0.is_some()); 663 assert!(multiple.1.is_some()); 664 } 665 666 #[test] 667 fn test_convert_to_criteria() { 668 let params = FilterQueryParams { 669 q: Some("conference".to_string()), 670 page: Some(1), 671 size: Some(20), 672 ..Default::default() 673 }; 674 675 let criteria = convert_to_criteria(&params, None).unwrap(); 676 assert_eq!(criteria.search_term, Some("conference".to_string())); 677 assert_eq!(criteria.page, 1); 678 assert_eq!(criteria.page_size, 20); 679 } 680 681 #[test] 682 fn test_parse_filter_query_params() { 683 let query = "q=event&modes=virtual,inperson&page=2&size=10"; 684 let params = parse_filter_query_params(query).unwrap(); 685 686 assert_eq!(params.q, Some("event".to_string())); 687 assert_eq!(params.modes, vec!["virtual".to_string(), "inperson".to_string()]); 688 assert_eq!(params.page, Some(2)); 689 assert_eq!(params.size, Some(10)); 690 } 691}