#![allow(clippy::unwrap_used, clippy::panic)]
use super::{
BeforeMutationGate, BeforeMutationOutcome, BeforeMutationRequest, enforce_before_mutation,
};
use crate::error::{FraiseQLError, Result};
struct Spy {
outcome: fn() -> Result<BeforeMutationOutcome>,
observed: std::sync::Mutex<Vec<(String, String, serde_json::Value)>>,
}
impl Spy {
fn new(outcome: fn() -> Result<BeforeMutationOutcome>) -> Self {
Self {
outcome,
observed: std::sync::Mutex::new(Vec::new()),
}
}
fn observed(&self) -> Vec<(String, String, serde_json::Value)> {
self.observed.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl BeforeMutationGate for Spy {
async fn before_mutation(
&self,
request: &BeforeMutationRequest<'_>,
) -> Result<BeforeMutationOutcome> {
self.observed.lock().unwrap().push((
request.mutation.to_string(),
request.response_key.to_string(),
request.arguments.clone(),
));
(self.outcome)()
}
}
struct StubReader {
reads: std::sync::Mutex<Vec<String>>,
}
impl crate::security::GuestQueryBridge for StubReader {
fn query<'a>(
&'a self,
graphql: &'a str,
_variables: Option<&'a serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send + 'a>>
{
self.reads.lock().unwrap().push(graphql.to_string());
Box::pin(async { Ok(serde_json::json!({ "data": {} })) })
}
}
fn reader_must_not_be_built() -> std::sync::Arc<dyn crate::security::GuestQueryBridge> {
panic!("the read bridge must not be built when no gate is configured");
}
fn stub_reader() -> std::sync::Arc<dyn crate::security::GuestQueryBridge> {
std::sync::Arc::new(StubReader {
reads: std::sync::Mutex::new(Vec::new()),
})
}
struct PanicIfCalled;
#[async_trait::async_trait]
impl BeforeMutationGate for PanicIfCalled {
async fn before_mutation(
&self,
_request: &BeforeMutationRequest<'_>,
) -> Result<BeforeMutationOutcome> {
panic!("gate must not be consulted");
}
}
#[tokio::test]
async fn no_gate_configured_is_a_no_op() {
let args = serde_json::json!({ "input": { "name": "Bob" } });
let out = enforce_before_mutation(
None,
None,
"createUser",
"createUser",
Some(&args),
reader_must_not_be_built,
)
.await
.expect("no gate cannot fail");
assert!(out.is_none(), "no gate must leave the arguments alone");
}
#[tokio::test]
#[should_panic(expected = "gate must not be consulted")]
async fn a_configured_gate_is_consulted() {
let gate = PanicIfCalled;
let _ =
enforce_before_mutation(Some(&gate), None, "createUser", "createUser", None, stub_reader)
.await;
}
#[tokio::test]
async fn proceed_leaves_the_arguments_unchanged() {
let gate = Spy::new(|| Ok(BeforeMutationOutcome::Proceed));
let args = serde_json::json!({ "input": { "name": "Bob" } });
let out = enforce_before_mutation(
Some(&gate),
None,
"createUser",
"created",
Some(&args),
stub_reader,
)
.await
.unwrap();
assert!(out.is_none(), "Proceed must not replace the arguments");
}
#[tokio::test]
async fn proceed_with_replaces_the_arguments() {
let gate = Spy::new(|| {
Ok(BeforeMutationOutcome::ProceedWith {
arguments: serde_json::json!({ "input": { "name": "REWRITTEN" } }),
})
});
let args = serde_json::json!({ "input": { "name": "Bob" } });
let out = enforce_before_mutation(
Some(&gate),
None,
"createUser",
"created",
Some(&args),
stub_reader,
)
.await
.unwrap()
.expect("ProceedWith must return the replacement");
assert_eq!(
out["input"]["name"], "REWRITTEN",
"the rewrite must be what the write binds from"
);
}
#[tokio::test]
async fn abort_refuses_the_write_with_the_rules_own_message() {
let gate = Spy::new(|| {
Ok(BeforeMutationOutcome::Abort {
reason: "amount exceeds the approval ceiling".to_string(),
})
});
let err = enforce_before_mutation(Some(&gate), None, "pay", "pay", None, stub_reader)
.await
.expect_err("Abort must refuse the write");
assert!(
matches!(&err, FraiseQLError::Validation { message, .. }
if message == "amount exceeds the approval ceiling"),
"the rule's own message must reach the client verbatim: {err:?}"
);
}
#[tokio::test]
async fn a_gate_error_refuses_the_write() {
let gate = Spy::new(|| {
Err(FraiseQLError::Internal {
message: "before:mutation hook execution failed".to_string(),
source: None,
})
});
let err = enforce_before_mutation(Some(&gate), None, "pay", "pay", None, stub_reader)
.await
.expect_err("a gate error must refuse the write, not proceed");
assert!(
matches!(err, FraiseQLError::Internal { .. }),
"the gate's error stands: {err:?}"
);
}
#[tokio::test]
async fn the_gate_sees_the_field_name_and_the_alias_separately() {
let gate = Spy::new(|| Ok(BeforeMutationOutcome::Proceed));
let args = serde_json::json!({ "id": 1 });
enforce_before_mutation(Some(&gate), None, "archiveUser", "archived", Some(&args), stub_reader)
.await
.unwrap();
assert_eq!(
gate.observed(),
vec![("archiveUser".to_string(), "archived".to_string(), args)],
"mutation must be the field name and response_key the alias"
);
}
#[tokio::test]
async fn an_argumentless_write_is_handed_null() {
let gate = Spy::new(|| Ok(BeforeMutationOutcome::Proceed));
enforce_before_mutation(Some(&gate), None, "reindex", "reindex", None, stub_reader)
.await
.unwrap();
assert_eq!(gate.observed()[0].2, serde_json::Value::Null);
}