From 7dbd8688eba1279f83a296293f926c608d41f10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Sat, 27 Jun 2026 01:36:20 +0000 Subject: [PATCH 1/4] expose created_at and updated_at in Event JSON response --- server/src/models/event.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/server/src/models/event.rs b/server/src/models/event.rs index 995d85a..4335780 100644 --- a/server/src/models/event.rs +++ b/server/src/models/event.rs @@ -70,7 +70,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)?; @@ -218,6 +218,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 { From 32465335b6a1e56c73724f03de3180c946cba03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Sat, 27 Jun 2026 01:37:08 +0000 Subject: [PATCH 2/4] add X-Request-ID propagation middleware to ensure header on all responses --- server/src/middleware/request_id_tracing.rs | 81 +++++++++++++++++++++ server/src/routes/mod.rs | 3 +- 2 files changed, 83 insertions(+), 1 deletion(-) 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/routes/mod.rs b/server/src/routes/mod.rs index eaa4688..d0a4950 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -62,7 +62,7 @@ use crate::handlers::{ use crate::middleware::audit::audit_layer; 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. @@ -265,6 +265,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()) } From bbaaf1cf0819c1b9c2f747448388dbd4ad7dbc0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Sat, 27 Jun 2026 01:37:41 +0000 Subject: [PATCH 3/4] reject page_size outside 1-100 in search_events with HTTP 400 --- server/src/handlers/events.rs | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/server/src/handlers/events.rs b/server/src/handlers/events.rs index e59897b..1b5c6ee 100644 --- a/server/src/handlers/events.rs +++ b/server/src/handlers/events.rs @@ -65,6 +65,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); @@ -725,6 +735,64 @@ mod tests { assert_eq!(Some(10u32).unwrap_or(5).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 { @@ -1715,6 +1783,10 @@ 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, From 220c7f60caa4b676d3e1cd0c8f62520afe8bef2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=99=B3=20=F0=9D=99=B2=F0=9D=99=B7=F0=9D=99=B4?= =?UTF-8?q?=F0=9D=99=B5?= Date: Sat, 27 Jun 2026 01:38:12 +0000 Subject: [PATCH 4/4] include location column in event keyword search full-text index --- server/src/handlers/events.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/handlers/events.rs b/server/src/handlers/events.rs index 1b5c6ee..59b4620 100644 --- a/server/src/handlers/events.rs +++ b/server/src/handlers/events.rs @@ -969,6 +969,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)] @@ -1822,12 +1837,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 )); }