#![allow(clippy::unwrap_used)] #![allow(clippy::missing_panics_doc)]
#[cfg(test)]
mod routing {
use super::super::routing::match_route_path;
use crate::routes::rest::resource::{
HttpMethod, RestResource, RestRoute, RestRouteTable, RouteSource,
};
fn make_test_route_table() -> RestRouteTable {
RestRouteTable {
base_path: "/rest/v1".to_string(),
resources: vec![RestResource {
name: "users".to_string(),
type_name: "User".to_string(),
id_arg: Some("id".to_string()),
routes: vec![
RestRoute {
method: HttpMethod::Get,
path: "/users".to_string(),
source: RouteSource::Query {
name: "users".to_string(),
},
update_coverage: None,
success_status: 200,
},
RestRoute {
method: HttpMethod::Get,
path: "/users/{id}".to_string(),
source: RouteSource::Query {
name: "user".to_string(),
},
update_coverage: None,
success_status: 200,
},
RestRoute {
method: HttpMethod::Post,
path: "/users".to_string(),
source: RouteSource::Mutation {
name: "createUser".to_string(),
},
update_coverage: None,
success_status: 201,
},
RestRoute {
method: HttpMethod::Put,
path: "/users/{id}".to_string(),
source: RouteSource::Mutation {
name: "updateUser".to_string(),
},
update_coverage: None,
success_status: 200,
},
RestRoute {
method: HttpMethod::Patch,
path: "/users/{id}".to_string(),
source: RouteSource::Mutation {
name: "updateUser".to_string(),
},
update_coverage: None,
success_status: 200,
},
RestRoute {
method: HttpMethod::Patch,
path: "/users/{id}/update-email".to_string(),
source: RouteSource::Mutation {
name: "updateUserEmail".to_string(),
},
update_coverage: None,
success_status: 200,
},
RestRoute {
method: HttpMethod::Delete,
path: "/users/{id}".to_string(),
source: RouteSource::Mutation {
name: "deleteUser".to_string(),
},
update_coverage: None,
success_status: 204,
},
RestRoute {
method: HttpMethod::Post,
path: "/users/{id}/archive".to_string(),
source: RouteSource::Mutation {
name: "archiveUser".to_string(),
},
update_coverage: None,
success_status: 200,
},
],
}],
diagnostics: Vec::new(),
}
}
#[test]
fn resolve_collection_get() {
let table = make_test_route_table();
let resolved = table.resolve("/users", HttpMethod::Get).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Query {
name: "users".to_string(),
}
);
assert!(resolved.path_params.is_empty());
}
#[test]
fn resolve_single_get() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42", HttpMethod::Get).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Query {
name: "user".to_string(),
}
);
assert_eq!(resolved.path_params.len(), 1);
assert_eq!(resolved.path_params[0], ("id".to_string(), "42".to_string()));
}
#[test]
fn resolve_post_collection() {
let table = make_test_route_table();
let resolved = table.resolve("/users", HttpMethod::Post).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "createUser".to_string(),
}
);
}
#[test]
fn resolve_put_single() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42", HttpMethod::Put).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "updateUser".to_string(),
}
);
}
#[test]
fn resolve_patch_single() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42", HttpMethod::Patch).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "updateUser".to_string(),
}
);
}
#[test]
fn resolve_patch_nested() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42/update-email", HttpMethod::Patch).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "updateUserEmail".to_string(),
}
);
assert_eq!(resolved.path_params.len(), 1);
}
#[test]
fn resolve_delete_single() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42", HttpMethod::Delete).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "deleteUser".to_string(),
}
);
}
#[test]
fn resolve_post_action() {
let table = make_test_route_table();
let resolved = table.resolve("/users/42/archive", HttpMethod::Post).unwrap();
assert_eq!(
resolved.route.source,
RouteSource::Mutation {
name: "archiveUser".to_string(),
}
);
}
#[test]
fn resolve_not_found() {
let table = make_test_route_table();
assert!(table.resolve("/nonexistent", HttpMethod::Get).is_none());
}
#[test]
fn resolve_wrong_method() {
let table = make_test_route_table();
assert!(table.resolve("/users", HttpMethod::Delete).is_none());
}
#[test]
fn match_route_path_static() {
let path_params = match_route_path("/users", &["users"]);
assert!(path_params.is_some());
assert!(path_params.unwrap().is_empty());
}
#[test]
fn match_route_path_dynamic() {
let path_params = match_route_path("/users/{id}", &["users", "42"]);
assert!(path_params.is_some());
let params = path_params.unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0], ("id".to_string(), "42".to_string()));
}
#[test]
fn match_route_path_multiple_params() {
let path_params =
match_route_path("/users/{uid}/posts/{pid}", &["users", "1", "posts", "2"]);
assert!(path_params.is_some());
let params = path_params.unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0].0, "uid");
assert_eq!(params[1].0, "pid");
}
#[test]
fn match_route_path_mismatch() {
let path_params = match_route_path("/users/{id}", &["posts", "42"]);
assert!(path_params.is_none());
}
#[test]
fn match_route_path_wrong_segment_count() {
let path_params = match_route_path("/users/{id}", &["users"]);
assert!(path_params.is_none());
}
}
#[cfg(test)]
mod prefer {
use axum::http::HeaderMap;
use super::super::prefer::{CountPreference, HandlingPreference, PreferHeader};
#[test]
fn prefer_parse_count_exact() {
let prefer = PreferHeader::parse("count=exact");
assert!(prefer.count_exact);
assert!(!prefer.return_representation);
assert!(!prefer.return_minimal);
}
#[test]
fn prefer_parse_return_representation() {
let prefer = PreferHeader::parse("return=representation");
assert!(!prefer.count_exact);
assert!(prefer.return_representation);
assert!(!prefer.return_minimal);
}
#[test]
fn prefer_parse_return_minimal() {
let prefer = PreferHeader::parse("return=minimal");
assert!(!prefer.count_exact);
assert!(!prefer.return_representation);
assert!(prefer.return_minimal);
}
#[test]
fn prefer_parse_combined() {
let prefer = PreferHeader::parse("count=exact, return=representation");
assert!(prefer.count_exact);
assert!(prefer.return_representation);
assert!(!prefer.return_minimal);
}
#[test]
fn prefer_parse_case_insensitive() {
let prefer = PreferHeader::parse("Count=Exact");
assert!(prefer.count_exact);
}
#[test]
fn prefer_parse_unknown_ignored() {
let prefer = PreferHeader::parse("respond-async, count=exact");
assert!(prefer.count_exact);
}
#[test]
fn prefer_minimal_overrides_representation() {
let prefer = PreferHeader::parse("return=representation, return=minimal");
assert!(prefer.return_minimal);
assert!(!prefer.return_representation);
}
#[test]
fn prefer_from_headers_multiple() {
let mut headers = HeaderMap::new();
headers.append("prefer", axum::http::HeaderValue::from_static("count=exact"));
headers.append("prefer", axum::http::HeaderValue::from_static("return=representation"));
let prefer = PreferHeader::from_headers(&headers);
assert!(prefer.count_exact);
assert!(prefer.return_representation);
}
#[test]
fn prefer_parse_resolution_merge() {
let prefer = PreferHeader::parse("resolution=merge-duplicates");
assert_eq!(prefer.resolution.as_deref(), Some("merge-duplicates"));
}
#[test]
fn prefer_parse_resolution_ignore() {
let prefer = PreferHeader::parse("resolution=ignore-duplicates");
assert_eq!(prefer.resolution.as_deref(), Some("ignore-duplicates"));
}
#[test]
fn prefer_parse_tx_rollback() {
let prefer = PreferHeader::parse("tx=rollback");
assert!(prefer.tx_rollback);
}
#[test]
fn prefer_parse_tx_commit() {
let prefer = PreferHeader::parse("tx=commit");
assert!(!prefer.tx_rollback);
}
#[test]
fn prefer_parse_handling_strict() {
let prefer = PreferHeader::parse("handling=strict");
assert_eq!(prefer.handling, Some(HandlingPreference::Strict));
}
#[test]
fn prefer_parse_handling_lenient() {
let prefer = PreferHeader::parse("handling=lenient");
assert_eq!(prefer.handling, Some(HandlingPreference::Lenient));
}
#[test]
fn prefer_parse_max_affected() {
let prefer = PreferHeader::parse("max-affected=100");
assert_eq!(prefer.max_affected, Some(100));
}
#[test]
fn prefer_count_preference_exact() {
let prefer = PreferHeader::parse("count=exact");
assert_eq!(prefer.count_preference(), Some(CountPreference::Exact));
}
#[test]
fn prefer_count_preference_none() {
let prefer = PreferHeader::parse("return=minimal");
assert_eq!(prefer.count_preference(), None);
}
}
#[cfg(test)]
mod headers {
use axum::http::HeaderMap;
use super::super::headers::{set_preference_applied, set_request_id};
#[test]
fn set_preference_applied_single() {
let mut headers = HeaderMap::new();
set_preference_applied(&mut headers, &["count=exact"]);
assert_eq!(headers.get("preference-applied").unwrap().to_str().unwrap(), "count=exact");
}
#[test]
fn set_preference_applied_multiple() {
let mut headers = HeaderMap::new();
set_preference_applied(&mut headers, &["count=exact", "return=representation"]);
let value = headers.get("preference-applied").unwrap().to_str().unwrap();
assert!(value.contains("count=exact"));
assert!(value.contains("return=representation"));
}
#[test]
fn set_preference_applied_empty() {
let mut headers = HeaderMap::new();
set_preference_applied(&mut headers, &[]);
assert!(headers.get("preference-applied").is_none());
}
#[test]
fn set_preference_applied_filters_empty() {
let mut headers = HeaderMap::new();
set_preference_applied(&mut headers, &["", "count=exact", ""]);
let value = headers.get("preference-applied").unwrap().to_str().unwrap();
assert_eq!(value, "count=exact");
}
#[test]
fn set_request_id_from_request() {
let mut request_headers = HeaderMap::new();
request_headers.insert("x-request-id", "test-id-123".parse().unwrap());
let mut response_headers = HeaderMap::new();
set_request_id(&request_headers, &mut response_headers);
assert_eq!(response_headers.get("x-request-id").unwrap().to_str().unwrap(), "test-id-123");
}
#[test]
fn set_request_id_generate_new() {
let request_headers = HeaderMap::new();
let mut response_headers = HeaderMap::new();
set_request_id(&request_headers, &mut response_headers);
let id = response_headers.get("x-request-id").unwrap().to_str().unwrap();
assert!(uuid::Uuid::parse_str(id).is_ok());
}
}
#[cfg(test)]
mod query {
use serde_json::json;
use super::super::query::{build_query_response, extract_relay_page_info};
use crate::routes::rest::params::PaginationParams;
#[test]
fn build_query_response_single() {
let result = json!({
"data": {
"user": {
"id": 1,
"name": "Alice"
}
}
});
let response = build_query_response(&result, None, &PaginationParams::None).unwrap();
assert_eq!(response["data"]["id"], 1);
assert!(!response.get("meta").is_some_and(|m| m.is_object()));
}
#[test]
fn build_query_response_with_offset_pagination() {
let result = json!({
"data": {
"users": [
{"id": 1},
{"id": 2}
]
}
});
let pagination = PaginationParams::Offset {
limit: 10,
offset: 0,
};
let response = build_query_response(&result, Some(100), &pagination).unwrap();
assert_eq!(response["meta"]["limit"], 10);
assert_eq!(response["meta"]["offset"], 0);
assert_eq!(response["meta"]["total"], 100);
}
#[test]
fn extract_relay_page_info_present() {
let data = json!({
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false
}
});
let info = extract_relay_page_info(&data);
assert!(info.is_some());
assert_eq!(info.unwrap()["hasNextPage"], true);
}
#[test]
fn extract_relay_page_info_missing() {
let data = json!({"items": []});
let info = extract_relay_page_info(&data);
assert!(info.is_none());
}
}
#[cfg(test)]
mod response {
use axum::http::StatusCode;
use serde_json::json;
use super::super::response::RestError;
#[test]
fn rest_error_bad_request() {
let err = RestError::bad_request("test message");
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert_eq!(err.code, "BAD_REQUEST");
assert_eq!(err.message, "test message");
}
#[test]
fn rest_error_forbidden() {
let err = RestError::forbidden();
assert_eq!(err.status, StatusCode::FORBIDDEN);
assert_eq!(err.code, "FORBIDDEN");
}
#[test]
fn rest_error_not_found() {
let err = RestError::not_found("resource not found");
assert_eq!(err.status, StatusCode::NOT_FOUND);
assert_eq!(err.code, "NOT_FOUND");
}
#[test]
fn rest_error_unprocessable_entity() {
let details = json!({"field": "name"});
let err = RestError::unprocessable_entity("invalid entity", details.clone());
assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(err.details, Some(details));
}
#[test]
fn rest_error_from_sqlstate_22_is_bad_user_input_400() {
let err = RestError::from(fraiseql_error::FraiseQLError::Database {
message: "invalid input syntax for type uuid: \"not-a-uuid\"".into(),
sql_state: Some("22P02".into()),
});
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert_eq!(err.code, "BAD_USER_INPUT");
assert!(err.message.contains("not-a-uuid"));
}
#[test]
fn rest_error_from_sqlstate_23_is_constraint_violation_400() {
for code in ["23502", "23503", "23505", "23514"] {
let err = RestError::from(fraiseql_error::FraiseQLError::Database {
message: "violates constraint".into(),
sql_state: Some(code.into()),
});
assert_eq!(err.status, StatusCode::BAD_REQUEST, "SQLSTATE {code}");
assert_eq!(err.code, "CONSTRAINT_VIOLATION", "SQLSTATE {code}");
}
}
#[test]
fn rest_error_from_non_client_database_stays_500() {
for sql_state in [Some("08006".to_string()), None] {
let err = RestError::from(fraiseql_error::FraiseQLError::Database {
message: "connection failure".into(),
sql_state,
});
assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(err.code, "INTERNAL_SERVER_ERROR");
}
let pool = RestError::from(fraiseql_error::FraiseQLError::ConnectionPool {
message: "pool exhausted".into(),
});
assert_eq!(pool.status, StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn rest_error_internal() {
let err = RestError::internal("internal error");
assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(err.code, "INTERNAL_SERVER_ERROR");
}
#[test]
fn rest_error_to_json() {
let err = RestError::bad_request("test error");
let json = err.to_json();
assert_eq!(json["error"]["code"], "BAD_REQUEST");
assert_eq!(json["error"]["message"], "test error");
}
#[test]
fn rest_error_to_json_with_details() {
let details = json!({"field": "email"});
let err = RestError::unprocessable_entity("validation error", details.clone());
let json = err.to_json();
assert_eq!(json["error"]["details"], details);
}
}
#[cfg(test)]
mod coercion {
use serde_json::json;
use super::super::coercion::coerce_path_param_value;
#[test]
fn coerce_path_param_value_integer() {
let val = coerce_path_param_value("42");
assert_eq!(val, json!(42i64));
}
#[test]
fn coerce_path_param_value_boolean_true() {
let val = coerce_path_param_value("true");
assert_eq!(val, json!(true));
}
#[test]
fn coerce_path_param_value_boolean_false() {
let val = coerce_path_param_value("false");
assert_eq!(val, json!(false));
}
#[test]
fn coerce_path_param_value_string() {
let val = coerce_path_param_value("hello");
assert_eq!(val, json!("hello"));
}
}
#[cfg(test)]
mod mutation {
use axum::http::{HeaderMap, StatusCode};
use serde_json::json;
use super::super::mutation::stored_response_to_rest;
use crate::routes::rest::idempotency::StoredResponse;
#[test]
fn stored_response_replay() {
let stored = StoredResponse {
status: 201,
headers: vec![("x-rows-affected".to_string(), "1".to_string())],
body: Some(json!({"id": 1})),
};
let request_headers = HeaderMap::new();
let rest = stored_response_to_rest(stored, &request_headers);
assert_eq!(rest.status, StatusCode::CREATED);
assert_eq!(rest.headers.get("idempotency-key").unwrap().to_str().unwrap(), "replayed=true");
assert_eq!(rest.body.unwrap()["id"], 1);
}
}
#[cfg(test)]
mod error_status {
use axum::http::StatusCode;
use fraiseql_error::FraiseQLError;
use crate::routes::rest::handler::response::RestError;
#[test]
fn rest_status_matches_canonical_status_code() {
let cases = vec![
FraiseQLError::validation("m"),
FraiseQLError::not_found("User", "1"),
FraiseQLError::Authorization {
message: "m".into(),
action: None,
resource: None,
},
FraiseQLError::Authentication {
message: "m".into(),
},
FraiseQLError::Conflict {
message: "m".into(),
},
FraiseQLError::RateLimited {
message: "m".into(),
retry_after_secs: 1,
},
FraiseQLError::Timeout {
timeout_ms: 1,
query: None,
},
FraiseQLError::cancelled("q", "r"),
FraiseQLError::Unsupported {
message: "m".into(),
},
FraiseQLError::ServiceUnavailable {
message: "m".into(),
retry_after: None,
},
FraiseQLError::Configuration {
message: "m".into(),
},
FraiseQLError::internal("m"),
];
for err in cases {
let expected = err.status_code();
let rest = RestError::from(err);
assert_eq!(
rest.status.as_u16(),
expected,
"REST status must equal the canonical status_code() ({expected}); got {} ({})",
rest.status.as_u16(),
rest.code,
);
}
}
#[test]
fn rest_maps_specific_variants_off_500() {
assert_eq!(
RestError::from(FraiseQLError::Conflict {
message: "x".into(),
})
.status,
StatusCode::CONFLICT,
);
assert_eq!(
RestError::from(FraiseQLError::RateLimited {
message: "x".into(),
retry_after_secs: 1,
})
.status,
StatusCode::TOO_MANY_REQUESTS,
);
assert_eq!(
RestError::from(FraiseQLError::ServiceUnavailable {
message: "x".into(),
retry_after: None,
})
.status,
StatusCode::SERVICE_UNAVAILABLE,
);
assert_eq!(
RestError::from(FraiseQLError::Unsupported {
message: "x".into(),
})
.status,
StatusCode::NOT_IMPLEMENTED,
);
}
#[test]
fn rest_database_client_input_is_400_server_fault_is_500() {
let client = FraiseQLError::Database {
message: "bad input".into(),
sql_state: Some("22001".into()),
};
assert_eq!(RestError::from(client).status, StatusCode::BAD_REQUEST);
let server = FraiseQLError::Database {
message: "boom".into(),
sql_state: None,
};
assert_eq!(RestError::from(server).status, StatusCode::INTERNAL_SERVER_ERROR);
}
}