#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use axum::http::HeaderMap;
use fraiseql_core::{
runtime::Executor,
schema::{CompiledSchema, FieldType, RestConfig},
};
use fraiseql_test_utils::{
failing_adapter::FailingAdapter,
schema_builder::{TestFieldBuilder, TestQueryBuilder, TestTypeBuilder},
};
use crate::routes::rest::{
handler::{RestHandler, routing::ResolvedGetQuery},
resource::RestRouteTable,
};
fn docs_schema() -> CompiledSchema {
let docs = TestQueryBuilder::new("docs", "Doc")
.returns_list(true)
.with_sql_source("v_doc")
.build();
let doc = TestTypeBuilder::new("Doc", "v_doc")
.with_field(TestFieldBuilder::new("id", FieldType::Int).build())
.with_field(TestFieldBuilder::new("title", FieldType::String).build())
.with_field(TestFieldBuilder::new("bodyText", FieldType::String).build())
.build();
let mut schema = CompiledSchema::new();
schema.queries.push(docs);
schema.types.push(doc);
schema.rest_config = Some(RestConfig {
enabled: true,
require_auth: false,
..RestConfig::default()
});
schema.build_indexes();
schema
}
fn resolve(query_pairs: &[(&str, &str)]) -> ResolvedGetQuery {
let schema = docs_schema();
let adapter = Arc::new(FailingAdapter::new());
let executor = Arc::new(Executor::new(schema.clone(), adapter));
let route_table = RestRouteTable::from_compiled_schema(&schema).unwrap();
let rest_config = schema.rest_config.clone().unwrap();
let handler = RestHandler::new(&executor, &schema, &rest_config, &route_table);
handler
.resolve_get_query("/docs", query_pairs, &HeaderMap::new(), None)
.expect("the documented default path of `?search=` must resolve")
}
#[test]
fn a_search_with_no_sort_carries_a_relevance_ordering_outside_the_arguments() {
let resolved = resolve(&[("search", "ada lovelace")]);
let relevance = resolved
.query_match
.search_relevance
.as_ref()
.expect("`?search=` with no `?sort=` is ranked by relevance");
assert_eq!(relevance.query, "ada lovelace");
assert_eq!(
relevance.fields,
vec!["title".to_string(), "body_text".to_string()],
"the rank covers the searchable fields, as the storage keys the predicate uses"
);
assert!(
!resolved.query_match.arguments.contains_key("orderBy"),
"the ranking is not a client argument: {:?}",
resolved.query_match.arguments
);
}
#[test]
fn a_search_still_builds_its_predicate() {
let resolved = resolve(&[("search", "ada")]);
let where_arg = resolved.query_match.arguments.get("where").expect("`?search=` filters");
let or = where_arg["_or"].as_array().expect("two searchable fields are OR-ed");
assert_eq!(or.len(), 2, "{where_arg}");
assert_eq!(or[0]["title"]["websearch_query"], "ada");
}
#[test]
fn an_explicit_sort_wins_and_leaves_no_ranking() {
let resolved = resolve(&[("search", "ada"), ("sort", "id")]);
assert!(
resolved.query_match.search_relevance.is_none(),
"the client named a sort; nothing should override it"
);
let order_by = resolved.query_match.arguments.get("orderBy").expect("the client's sort");
assert_eq!(order_by[0]["field"], "id");
}
#[test]
fn a_read_without_a_search_carries_no_ranking() {
let resolved = resolve(&[]);
assert!(resolved.query_match.search_relevance.is_none());
assert!(!resolved.query_match.arguments.contains_key("orderBy"));
}
#[test]
fn a_search_neither_matches_nor_ranks_a_field_the_caller_may_not_read() {
let mut schema = docs_schema();
let doc = schema.types.iter_mut().find(|t| t.name == "Doc").unwrap();
let body = doc.fields.iter_mut().find(|f| f.name == "bodyText").unwrap();
body.requires_scope = Some("read:body".to_string());
schema.security = Some(fraiseql_core::schema::SecurityConfig::default());
schema.build_indexes();
let adapter = Arc::new(FailingAdapter::new());
let executor = Arc::new(Executor::new(schema.clone(), adapter));
let route_table = RestRouteTable::from_compiled_schema(&schema).unwrap();
let rest_config = schema.rest_config.clone().unwrap();
let handler = RestHandler::new(&executor, &schema, &rest_config, &route_table);
let resolved = handler
.resolve_get_query("/docs", &[("search", "ada")], &HeaderMap::new(), None)
.expect("a search over the readable fields still resolves");
let relevance = resolved.query_match.search_relevance.as_ref().expect("ranked");
assert_eq!(
relevance.fields,
vec!["title".to_string()],
"ranks only what the caller may read"
);
let filter = resolved
.query_match
.arguments
.get("where")
.expect("a search filters")
.to_string();
assert!(!filter.contains("bodyText"), "matches only what the caller may read: {filter}");
}
#[test]
fn a_search_with_nothing_the_caller_may_read_is_refused() {
let mut schema = docs_schema();
let doc = schema.types.iter_mut().find(|t| t.name == "Doc").unwrap();
for field in doc.fields.iter_mut().filter(|f| f.name != "id") {
field.requires_scope = Some("read:doc".to_string());
}
schema.security = Some(fraiseql_core::schema::SecurityConfig::default());
schema.build_indexes();
let adapter = Arc::new(FailingAdapter::new());
let executor = Arc::new(Executor::new(schema.clone(), adapter));
let route_table = RestRouteTable::from_compiled_schema(&schema).unwrap();
let rest_config = schema.rest_config.clone().unwrap();
let handler = RestHandler::new(&executor, &schema, &rest_config, &route_table);
let err = handler
.resolve_get_query("/docs", &[("search", "ada")], &HeaderMap::new(), None)
.err()
.expect("a search over nothing readable must not resolve to an unfiltered read");
assert_eq!(err.status, http::StatusCode::FORBIDDEN);
}