Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions server/src/handlers/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -1880,6 +1963,11 @@ pub async fn search_events(
State(mut state): State<EventState>,
Query(params): Query<SearchParams>,
) -> 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,
Expand Down Expand Up @@ -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
));
}

Expand Down
81 changes: 81 additions & 0 deletions server/src/middleware/request_id_tracing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
29 changes: 28 additions & 1 deletion server/src/models/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())
}
Expand Down
Loading