forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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(¶ms.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(¶ms.modes);
302 }
303
304 // Statuses
305 if !params.statuses.is_empty() {
306 criteria.statuses = parse_statuses_from_vec(¶ms.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(¶ms, 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}