#![allow(clippy::unwrap_used)]
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use chrono::Utc;
use fraiseql_core::{
db::{
traits::{DatabaseAdapter, SupportsMutations},
types::{DatabaseType, JsonbValue, OrderByClause, PoolMetrics},
where_clause::WhereClause,
},
error::Result,
runtime::Executor,
schema::{CompiledSchema, SqlProjectionHint},
security::SecurityContext,
};
use serde_json::json;
fn mutation_success_row() -> HashMap<String, serde_json::Value> {
let mut row = HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert("message".to_string(), json!("ok"));
row.insert(
"entity".to_string(),
json!({"id": "abc-123", "email": "a@b.com", "name": "Alice"}),
);
row.insert("entity_type".to_string(), json!("User"));
row
}
fn order_success_row() -> HashMap<String, serde_json::Value> {
let mut row = HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert("message".to_string(), json!("ok"));
row.insert(
"entity".to_string(),
json!({"id": "order-456", "tenant_id": "t-1", "amount": "99.99", "status": "pending"}),
);
row.insert("entity_type".to_string(), json!("Order"));
row
}
fn admin_security_context() -> SecurityContext {
SecurityContext {
user_id: fraiseql_core::types::UserId::new("user-123"),
tenant_id: Some(fraiseql_core::types::TenantId::new("tenant-456")),
roles: vec!["admin".to_string()],
scopes: vec![],
attributes: HashMap::new(),
request_id: "req-test".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
struct RecordingMockAdapter {
called_fn: std::sync::Mutex<Option<String>>,
called_args: std::sync::Mutex<Vec<serde_json::Value>>,
response_row: HashMap<String, serde_json::Value>,
}
impl RecordingMockAdapter {
const fn new(response_row: HashMap<String, serde_json::Value>) -> Self {
Self {
called_fn: std::sync::Mutex::new(None),
called_args: std::sync::Mutex::new(vec![]),
response_row,
}
}
fn called_function(&self) -> Option<String> {
self.called_fn.lock().unwrap().clone()
}
fn called_args(&self) -> Vec<serde_json::Value> {
self.called_args.lock().unwrap().clone()
}
}
#[async_trait]
impl DatabaseAdapter for RecordingMockAdapter {
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
async fn execute_where_query(
&self,
_view: &str,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
fn database_type(&self) -> DatabaseType {
DatabaseType::PostgreSQL
}
fn pool_metrics(&self) -> PoolMetrics {
PoolMetrics {
total_connections: 1,
active_connections: 0,
idle_connections: 1,
waiting_requests: 0,
}
}
async fn execute_raw_query(
&self,
_sql: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
async fn execute_function_call(
&self,
function_name: &str,
args: &[serde_json::Value],
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
*self.called_fn.lock().unwrap() = Some(function_name.to_string());
*self.called_args.lock().unwrap() = args.to_vec();
Ok(vec![self.response_row.clone()])
}
}
impl SupportsMutations for RecordingMockAdapter {}
#[tokio::test]
async fn mutation_executor_uses_sql_source_from_compiled_schema() {
let json = include_str!("../../../tests/fixtures/golden/01-basic-query-mutation.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let m = schema
.find_mutation("createUser")
.expect("'createUser' must be in golden fixture 01");
assert_eq!(
m.sql_source.as_deref(),
Some("fn_create_user"),
"pre-condition: golden fixture 01 must have sql_source=fn_create_user"
);
let mock = Arc::new(RecordingMockAdapter::new(mutation_success_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = serde_json::json!({"email": "a@b.com", "name": "Alice"});
let result = executor.execute(r"mutation { createUser { id } }", Some(&vars)).await;
assert!(result.is_ok(), "mutation must succeed: {result:?}");
assert_eq!(
mock.called_function().as_deref(),
Some("fn_create_user"),
"executor must pass sql_source to execute_function_call — regression for #53"
);
}
#[tokio::test]
async fn mutation_executor_passes_arguments_to_function_call() {
let json = include_str!("../../../tests/fixtures/golden/01-basic-query-mutation.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(mutation_success_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = serde_json::json!({"email": "test@example.com", "name": "Bob"});
let result = executor.execute(r"mutation { createUser { id } }", Some(&vars)).await;
assert!(result.is_ok(), "mutation with arguments must succeed: {result:?}");
let args = mock.called_args();
assert!(
!args.is_empty(),
"mutation arguments must be forwarded to execute_function_call"
);
}
#[tokio::test]
async fn mutation_executor_wraps_response_in_data_envelope() {
let json = include_str!("../../../tests/fixtures/golden/01-basic-query-mutation.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(mutation_success_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = serde_json::json!({"email": "a@b.com", "name": "Alice"});
let result = executor
.execute(r"mutation { createUser { id } }", Some(&vars))
.await
.expect("mutation must succeed");
assert!(result.get("data").is_some(), "response must have 'data' key");
assert!(
result.get("errors").is_none(),
"successful mutation must not produce 'errors': {result}"
);
}
#[tokio::test]
async fn mutation_executor_appends_inject_params_from_jwt() {
let json = include_str!("../../../tests/fixtures/golden/05-security-inject-cache.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let m = schema
.find_mutation("createOrder")
.expect("'createOrder' must be in golden fixture 05");
assert!(
!m.inject_params.is_empty(),
"pre-condition: createOrder must have inject_params in fixture 05"
);
let mock = Arc::new(RecordingMockAdapter::new(order_success_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let ctx = admin_security_context();
let vars = serde_json::json!({"amount": "99.99"});
let result = executor
.execute_with_security(r"mutation { createOrder { id } }", Some(&vars), &ctx)
.await;
assert!(result.is_ok(), "mutation with inject_params must succeed: {result:?}");
assert_eq!(
mock.called_function().as_deref(),
Some("fn_create_order"),
"executor must use sql_source fn_create_order from fixture 05"
);
let args = mock.called_args();
assert!(!args.is_empty(), "inject_params must cause at least one argument to be passed");
let has_user_id = args.iter().any(|v| v.as_str() == Some("user-123"));
assert!(
has_user_id,
"resolved inject_param user_id='user-123' must appear in args: {args:?}"
);
}
fn mutation_error_row() -> HashMap<String, serde_json::Value> {
let mut row = HashMap::new();
row.insert("succeeded".to_string(), json!(false));
row.insert("state_changed".to_string(), json!(false));
row.insert("error_class".to_string(), json!("conflict"));
row.insert("message".to_string(), json!("email already exists"));
row.insert(
"error_detail".to_string(),
json!({
"message": "email already exists",
"conflicting_id": "existing-user-id",
"code": 409,
"affected_ids": ["id-1", "id-2"],
"details": {"field": "email", "rule": "unique"}
}),
);
row
}
#[tokio::test]
async fn error_path_applies_selection_filtering() {
let json = include_str!("../../../tests/fixtures/golden/09-mutation-error-union.json");
let schema = CompiledSchema::from_json(json, false).expect("fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(mutation_error_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = json!({"email": "dup@example.com", "name": "Alice"});
let result = executor
.execute(
r#"mutation { createUser(email: "dup@example.com", name: "Alice") { __typename ... on DuplicateEmailError { message code } } }"#,
Some(&vars),
)
.await
.expect("mutation must succeed even on error outcome");
let data = &result["data"]["createUser"];
assert_eq!(data["__typename"], "DuplicateEmailError");
assert_eq!(data["message"], "email already exists");
assert_eq!(data["code"], 409);
assert!(
data.get("conflicting_id").is_none(),
"unrequested field 'conflicting_id' must be filtered out: {data}"
);
assert!(
data.get("affected_ids").is_none(),
"unrequested field 'affected_ids' must be filtered out: {data}"
);
assert!(
data.get("details").is_none(),
"unrequested field 'details' must be filtered out: {data}"
);
}
#[tokio::test]
async fn error_path_populates_array_fields() {
let json = include_str!("../../../tests/fixtures/golden/09-mutation-error-union.json");
let schema = CompiledSchema::from_json(json, false).expect("fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(mutation_error_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = json!({"email": "dup@example.com", "name": "Alice"});
let result = executor
.execute(
r#"mutation { createUser(email: "dup@example.com", name: "Alice") { __typename ... on DuplicateEmailError { message affected_ids } } }"#,
Some(&vars),
)
.await
.expect("mutation must succeed");
let data = &result["data"]["createUser"];
assert_eq!(data["__typename"], "DuplicateEmailError");
let arr = data["affected_ids"].as_array().expect("affected_ids must be an array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0], "id-1");
assert_eq!(arr[1], "id-2");
}
#[tokio::test]
async fn error_path_populates_nested_object_fields() {
let json = include_str!("../../../tests/fixtures/golden/09-mutation-error-union.json");
let schema = CompiledSchema::from_json(json, false).expect("fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(mutation_error_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = json!({"email": "dup@example.com", "name": "Alice"});
let result = executor
.execute(
r#"mutation { createUser(email: "dup@example.com", name: "Alice") { ... on DuplicateEmailError { message details } } }"#,
Some(&vars),
)
.await
.expect("mutation must succeed");
let data = &result["data"]["createUser"];
assert_eq!(data["details"]["field"], "email");
assert_eq!(data["details"]["rule"], "unique");
}
#[tokio::test]
async fn non_cascade_mutation_does_not_surface_cascade() {
let json = include_str!("../../../tests/fixtures/golden/01-basic-query-mutation.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let cascade_payload = json!({
"updated": [ { "__typename": "User", "id": "abc-123" } ],
"deleted": [],
});
let mut row = mutation_success_row();
row.insert("cascade".to_string(), cascade_payload);
let mock = Arc::new(RecordingMockAdapter::new(row));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = serde_json::json!({"email": "a@b.com", "name": "Alice"});
let result = executor
.execute(r"mutation { createUser { id } }", Some(&vars))
.await
.expect("mutation must succeed");
let entity = &result["data"]["createUser"];
assert_eq!(entity["id"], "abc-123", "the primary entity is still projected");
assert!(
entity.get("cascade").is_none(),
"a non-cascade mutation must NOT surface cascade (finding 3): {result}"
);
}
#[tokio::test]
async fn mutation_executor_rejects_inject_params_without_security_context() {
let json = include_str!("../../../tests/fixtures/golden/05-security-inject-cache.json");
let schema = CompiledSchema::from_json(json, false).expect("golden fixture must parse");
let mock = Arc::new(RecordingMockAdapter::new(order_success_row()));
let executor = Executor::new(schema, Arc::clone(&mock));
let vars = serde_json::json!({"amount": "99.99"});
let result = executor.execute(r"mutation { createOrder { id } }", Some(&vars)).await;
assert!(result.is_err(), "mutation with inject_params must fail without SecurityContext");
}