diff --git a/server/src/handlers/events.rs b/server/src/handlers/events.rs index ad485a4..80722a2 100644 --- a/server/src/handlers/events.rs +++ b/server/src/handlers/events.rs @@ -66,6 +66,16 @@ fn default_page_size() -> u32 { 20 } +impl SearchParams { + fn validate_page_size(&self) -> Result<(), String> { + if self.page_size == 0 || self.page_size > 100 { + Err("page_size must be between 1 and 100".to_string()) + } else { + Ok(()) + } + } +} + /// Cache TTL for event details (5 minutes) const EVENT_CACHE_TTL: Duration = Duration::from_secs(300); @@ -768,6 +778,64 @@ mod tests { assert_eq!(10u32.clamp(1, 20), 10); } + #[test] + fn test_search_params_page_size_valid() { + for size in [1u32, 20, 50, 100] { + let params = SearchParams { + q: None, + category_id: None, + category_ids: None, + min_price: None, + max_price: None, + date_from: None, + date_to: None, + location: None, + ticket_type: None, + page: 1, + page_size: size, + }; + assert!(params.validate_page_size().is_ok(), "page_size={} should be valid", size); + } + } + + #[test] + fn test_search_params_page_size_zero_rejected() { + let params = SearchParams { + q: None, + category_id: None, + category_ids: None, + min_price: None, + max_price: None, + date_from: None, + date_to: None, + location: None, + ticket_type: None, + page: 1, + page_size: 0, + }; + let err = params.validate_page_size().unwrap_err(); + assert!(err.contains("page_size must be between 1 and 100")); + } + + #[test] + fn test_search_params_page_size_above_max_rejected() { + let params = SearchParams { + q: None, + category_id: None, + category_ids: None, + min_price: None, + max_price: None, + date_from: None, + date_to: None, + location: None, + ticket_type: None, + page: 1, + page_size: 101, + }; + let err = params.validate_page_size().unwrap_err(); + assert!(err.contains("page_size must be between 1 and 100")); + } + #[test] fn test_search_params_ticket_type() { let params = SearchParams { @@ -985,6 +1053,21 @@ mod tests { let err = filters.validate_sort().unwrap_err(); assert!(err.contains("Invalid sort_order value 'sideways'")); } + + #[test] + fn test_keyword_search_clause_includes_location() { + // Mirrors the format string used inside search_events for the `q` param. + let param_count = 1usize; + let clause = format!( + "(e.title ILIKE ${0} OR e.description ILIKE ${0} OR e.location ILIKE ${0})", + param_count + ); + assert!( + clause.contains("e.location ILIKE $1"), + "keyword search must include location column, got: {}", + clause + ); + } } #[derive(Debug, Deserialize)] @@ -1880,6 +1963,11 @@ pub async fn search_events( State(mut state): State, Query(params): Query, ) -> Response { + if let Err(msg) = params.validate_page_size() { + return AppError::ValidationError(msg).into_response(); + } + + let start = std::time::Instant::now(); let pagination = PaginationParams { page: params.page, page_size: params.page_size, @@ -1925,12 +2013,12 @@ pub async fn search_events( let mut where_clauses = vec!["1=1".to_string()]; let mut param_count = 0; - // Keyword search in title and description + // Keyword search in title, description, and location if params.q.is_some() { param_count += 1; where_clauses.push(format!( - "(e.title ILIKE ${} OR e.description ILIKE ${})", - param_count, param_count + "(e.title ILIKE ${0} OR e.description ILIKE ${0} OR e.location ILIKE ${0})", + param_count )); } diff --git a/server/src/middleware/request_id_tracing.rs b/server/src/middleware/request_id_tracing.rs index 0e0e7b4..d63a367 100644 --- a/server/src/middleware/request_id_tracing.rs +++ b/server/src/middleware/request_id_tracing.rs @@ -27,3 +27,84 @@ pub async fn trace_request_id(request: Request, next: Next) -> Response { let span = tracing::info_span!("request", request_id = %request_id); next.run(request).instrument(span).await } + +/// Axum middleware that copies `x-request-id` from the request headers to the +/// response. This guarantees the header appears on every response — including +/// error responses — even when the inner handler creates a fresh `Response` +/// that does not carry the header. +/// +/// Must be applied after [`SetRequestIdLayer`] so the header is already set on +/// the incoming request. +pub async fn propagate_request_id(request: Request, next: Next) -> Response { + let request_id = request + .headers() + .get(REQUEST_ID_HEADER) + .cloned(); + + let mut response = next.run(request).await; + + if let Some(id) = request_id { + let header_name = axum::http::HeaderName::from_static(REQUEST_ID_HEADER); + response.headers_mut().entry(header_name).or_insert(id); + } + + response +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request, http::StatusCode, middleware, routing::get, Router}; + use crate::config::request_id::set_request_id_layer; + use tower::ServiceExt; + + #[tokio::test] + async fn test_propagate_request_id_on_success_response() { + let router = Router::new() + .route("/", get(|| async { "ok" })) + .layer(middleware::from_fn(propagate_request_id)) + .layer(set_request_id_layer()); + + let custom_id = "test-id-success"; + let req = Request::builder() + .uri("/") + .header(REQUEST_ID_HEADER, custom_id) + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + + assert_eq!( + resp.headers() + .get(REQUEST_ID_HEADER) + .and_then(|v| v.to_str().ok()), + Some(custom_id) + ); + } + + #[tokio::test] + async fn test_propagate_request_id_on_error_response() { + let router = Router::new() + .route( + "/", + get(|| async { StatusCode::BAD_REQUEST }), + ) + .layer(middleware::from_fn(propagate_request_id)) + .layer(set_request_id_layer()); + + let custom_id = "test-id-error"; + let req = Request::builder() + .uri("/") + .header(REQUEST_ID_HEADER, custom_id) + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!( + resp.headers() + .get(REQUEST_ID_HEADER) + .and_then(|v| v.to_str().ok()), + Some(custom_id) + ); + } +} diff --git a/server/src/models/event.rs b/server/src/models/event.rs index b9d8beb..00097e5 100644 --- a/server/src/models/event.rs +++ b/server/src/models/event.rs @@ -72,7 +72,7 @@ impl Serialize for Event { { use serde::ser::SerializeStruct; - let mut state = serializer.serialize_struct("Event", 16)?; + let mut state = serializer.serialize_struct("Event", 15)?; state.serialize_field("id", &self.id)?; state.serialize_field("organizer_id", &self.organizer_id)?; state.serialize_field("title", &self.title)?; @@ -221,6 +221,33 @@ mod tests { assert_eq!(json["average_rating"], 4.5); } + #[test] + fn test_created_at_and_updated_at_serialized() { + use chrono::TimeZone; + let created = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).unwrap(); + let updated = Utc.with_ymd_and_hms(2026, 5, 20, 14, 30, 0).unwrap(); + let event = Event { + id: Uuid::new_v4(), + organizer_id: Uuid::new_v4(), + title: "T".into(), + description: None, + location: "L".into(), + start_time: created, + end_time: None, + is_flagged: false, + sum_of_ratings: 0, + count_of_ratings: 0, + created_at: created, + updated_at: updated, + image_url: None, + is_free: false, + minted_tickets: 0, + }; + let json = serde_json::to_value(&event).unwrap(); + assert!(!json["created_at"].is_null(), "created_at must be present"); + assert!(!json["updated_at"].is_null(), "updated_at must be present"); + } + #[test] fn test_average_rating_serialized_null_when_no_ratings() { let event = Event { diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 362b385..d0b4156 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -62,7 +62,7 @@ use crate::middleware::audit::audit_layer; use crate::middleware::content_type::require_json_content_type; use crate::middleware::monitoring_auth::{require_monitoring_token, MonitoringAuthState}; use crate::middleware::rate_limit::GovernorRateLimitLayer; -use crate::middleware::request_id_tracing::trace_request_id; +use crate::middleware::request_id_tracing::{propagate_request_id, trace_request_id}; use crate::utils::rate_limit::RateLimitLayer; /// Sensitive routes that hit the database or expose internal state. @@ -274,6 +274,7 @@ pub async fn create_routes(pool: PgPool, config: Config, redis: RedisCache) -> R .layer(create_security_headers_layer()) .layer(create_cors_layer()) .layer(middleware::from_fn(trace_request_id)) + .layer(middleware::from_fn(propagate_request_id)) .layer(propagate_request_id_layer()) .layer(set_request_id_layer()) }