#![allow(clippy::unwrap_used, clippy::panic)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)]
use std::io::Write as _;
use tempfile::NamedTempFile;
use super::loader::{CompiledSchemaLoader, SchemaLoadError};
fn write_schema(json: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(json.as_bytes()).unwrap();
file.flush().unwrap();
file
}
fn minimal_schema() -> &'static str {
r#"{"types": []}"#
}
#[tokio::test]
async fn test_loader_not_found() {
let loader = CompiledSchemaLoader::new("/nonexistent/path/schema.json");
let result = loader.load().await;
assert!(matches!(result, Err(SchemaLoadError::NotFound(_))));
}
#[tokio::test]
async fn test_loader_invalid_json() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "{{invalid json").unwrap();
file.flush().unwrap();
let loader = CompiledSchemaLoader::new(file.path());
let result = loader.load().await;
assert!(matches!(result, Err(SchemaLoadError::ParseError(_))));
}
#[tokio::test]
async fn a_storage_section_in_the_compiled_schema_is_refused() {
let json = r#"{
"types": [],
"storage": {
"buckets": [
{"name": "avatars", "access": "private"},
{"name": "media", "access": "public_read", "max_object_bytes": 5242880}
]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let err = loader.load_extended().await.expect_err(
"a compiled-schema `storage` section must be refused, not accepted and dropped",
);
assert!(
matches!(err, SchemaLoadError::ValidationError(_)),
"expected ValidationError, got {err:?}"
);
assert!(
err.to_string().contains("[storage]"),
"the refusal must name the working surface so the author can move the config there: {err}"
);
}
#[tokio::test]
async fn a_null_storage_key_is_not_refused() {
let file = write_schema(r#"{"types": [], "storage": null}"#);
let loader = CompiledSchemaLoader::new(file.path());
assert!(
loader.load_extended().await.is_ok(),
"a null `storage` key declares nothing and must not fail the boot"
);
}
#[tokio::test]
async fn test_schema_without_storage_returns_none() {
let file = write_schema(minimal_schema());
let loader = CompiledSchemaLoader::new(file.path());
assert!(loader.load_extended().await.is_ok());
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn test_schema_loads_functions_config() {
let json = r#"{
"types": [],
"functions": {
"module_dir": "/opt/fraiseql/functions",
"definitions": [
{
"name": "on_create_user",
"trigger": "after:mutation:createUser",
"runtime": "Wasm"
},
{
"name": "validate_user",
"trigger": "before:mutation:createUser",
"runtime": "Wasm",
"timeout_ms": 300
}
]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.unwrap();
let functions = extended.functions.unwrap();
assert_eq!(functions.definitions.len(), 2);
assert_eq!(functions.definitions[0].name, "on_create_user");
assert_eq!(functions.definitions[0].trigger, "after:mutation:createUser");
assert_eq!(functions.definitions[1].timeout_ms, Some(300));
assert_eq!(functions.module_dir, std::path::PathBuf::from("/opt/fraiseql/functions"));
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn a_capture_or_ingest_trigger_is_not_rejected_by_the_loader() {
for trigger in ["after:capture:User:update", "after:ingest:email"] {
let json = format!(
r#"{{
"types": [],
"functions": {{
"module_dir": "/opt/fraiseql/functions",
"definitions": [
{{"name": "fn_under_test", "trigger": "{trigger}", "runtime": "Wasm"}}
]
}}
}}"#
);
let file = write_schema(&json);
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.unwrap_or_else(|e| {
panic!(
"`{trigger}` is a trigger the registry parses and dispatches, but the loader \
refused the schema: {e}"
)
});
assert_eq!(
extended.functions.expect("the section must load").definitions[0].trigger,
trigger
);
}
}
fn function_backed_schema(functions: &str) -> String {
format!(
r#"{{
"types": [{{"name": "Quote", "sql_source": "v_quote",
"fields": [{{"name": "id", "field_type": "ID", "nullable": false}}]}}],
"queries": [{{"name": "quotePreview", "return_type": "Quote",
"function": "preview_quote"}}],
"mutations": []{functions}
}}"#
)
}
#[tokio::test]
async fn a_query_bound_to_a_missing_function_refuses_to_boot() {
let file = write_schema(&function_backed_schema(""));
let loader = CompiledSchemaLoader::new(file.path());
let error = loader
.load_extended()
.await
.expect_err("a query bound to nothing must refuse to boot (#1329)");
let message = error.to_string();
assert!(
message.contains("quotePreview") && message.contains("preview_quote"),
"the refusal must name the query and the function it could not resolve; got: {message}"
);
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn a_query_bound_to_an_event_triggered_function_refuses_to_boot() {
let functions = r#",
"functions": {"module_dir": "/opt/fraiseql/functions",
"definitions": [{"name": "preview_quote",
"trigger": "after:mutation:Quote:update",
"runtime": "Wasm"}]}"#;
let file = write_schema(&function_backed_schema(functions));
let loader = CompiledSchemaLoader::new(file.path());
let message = loader
.load_extended()
.await
.expect_err("an event-triggered function may not back a query (#1329)")
.to_string();
assert!(
message.contains("after:mutation:Quote:update") && message.contains("request:query"),
"the refusal must name the trigger it found and the one it needs; got: {message}"
);
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn a_request_query_function_no_query_names_refuses_to_boot() {
let json = r#"{
"types": [],
"functions": {"module_dir": "/opt/fraiseql/functions",
"definitions": [{"name": "preview_quote",
"trigger": "request:query",
"runtime": "Wasm"}]}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let message = loader
.load_extended()
.await
.expect_err("a function nothing can invoke must refuse to boot (#1329)")
.to_string();
assert!(
message.contains("preview_quote"),
"the refusal must name the function; got: {message}"
);
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn a_paired_function_backed_query_boots() {
let functions = r#",
"functions": {"module_dir": "/opt/fraiseql/functions",
"definitions": [{"name": "preview_quote",
"trigger": "request:query",
"runtime": "Wasm"}]}"#;
let file = write_schema(&function_backed_schema(functions));
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.expect("a paired function-backed query must boot");
assert_eq!(
extended
.schema
.queries
.iter()
.find(|q| q.name == "quotePreview")
.expect("the query must load")
.function
.as_deref(),
Some("preview_quote")
);
}
#[tokio::test]
async fn test_schema_without_functions_returns_none() {
let file = write_schema(minimal_schema());
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.unwrap();
assert!(extended.functions.is_none());
}
#[tokio::test]
async fn test_schema_validates_function_triggers() {
let json = r#"{
"types": [],
"functions": {
"module_dir": "/opt/fraiseql/functions",
"definitions": [
{
"name": "bad_fn",
"trigger": "unknown_trigger_format",
"runtime": "Wasm"
}
]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let result = loader.load_extended().await;
let message = match result {
Err(SchemaLoadError::ValidationError(m)) => m,
other => panic!("expected ValidationError for unknown trigger format, got {other:?}"),
};
assert!(
message.contains("unknown_trigger_format"),
"the error must name the rejected trigger, got: {message}"
);
}
#[cfg(not(feature = "functions-runtime"))]
#[tokio::test]
async fn a_functions_section_is_refused_without_a_runtime() {
let json = r#"{
"types": [],
"functions": {
"module_dir": "/opt/fraiseql/functions",
"definitions": [
{"name": "on_create", "trigger": "after:mutation:createUser", "runtime": "Wasm"}
]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let message = match loader.load_extended().await {
Err(SchemaLoadError::ValidationError(m)) => m,
other => panic!(
"a build with no function runtime must refuse a declared `functions` section, \
not load and drop it; got {other:?}"
),
};
assert!(message.contains("functions"), "the error must name the section: {message}");
assert!(
message.contains("functions-runtime"),
"the error must name the missing feature, or an operator cannot act on it: {message}"
);
assert!(
message.contains("-platform"),
"the error must name the remedy — the image tag that can run it: {message}"
);
}
#[cfg(not(feature = "functions-runtime"))]
#[test]
fn gated_sections_reports_functions_as_absent_in_a_lean_build() {
let sections = super::loader::gated_sections();
let functions = sections.iter().find(|s| s.name == "functions").expect("listed");
assert!(!functions.compiled_in, "this build has no `functions-runtime`");
}
#[cfg(feature = "functions-runtime")]
#[test]
fn gated_sections_reports_functions_as_present_with_the_runtime() {
let sections = super::loader::gated_sections();
let functions = sections.iter().find(|s| s.name == "functions").expect("listed");
assert!(functions.compiled_in, "this build has `functions-runtime`");
}
fn probe(name: &'static str, compiled_in: bool) -> super::loader::GatedSection {
super::loader::GatedSection {
name,
feature: "some-feature",
compiled_in,
remedy: "pull the `-platform` image tag",
activity: super::loader::Activity::NonEmptyDefinitions,
}
}
fn probe_with(
name: &'static str,
compiled_in: bool,
activity: super::loader::Activity,
) -> super::loader::GatedSection {
super::loader::GatedSection {
activity,
..probe(name, compiled_in)
}
}
#[test]
fn a_declared_section_this_build_cannot_serve_is_refused() {
let raw = serde_json::json!({ "types": [], "functions": { "definitions": [{ "name": "f" }] } });
let err = super::loader::refuse_unservable_sections(&raw, &[probe("functions", false)])
.expect_err("a section nothing can serve must be refused");
assert!(matches!(err, SchemaLoadError::ValidationError(_)), "{err:?}");
}
#[test]
fn a_declared_section_this_build_can_serve_is_accepted() {
let raw = serde_json::json!({ "types": [], "functions": { "definitions": [{ "name": "f" }] } });
assert!(
super::loader::refuse_unservable_sections(&raw, &[probe("functions", true)]).is_ok(),
"a servable section must load"
);
}
#[test]
fn an_undeclared_section_is_not_refused() {
let raw = serde_json::json!({ "types": [] });
assert!(
super::loader::refuse_unservable_sections(&raw, &[probe("functions", false)]).is_ok(),
"a lean build must still boot a schema that declares nothing it cannot serve"
);
}
#[test]
fn a_null_section_is_not_a_declaration() {
let raw = serde_json::json!({ "types": [], "functions": serde_json::Value::Null });
assert!(
super::loader::refuse_unservable_sections(&raw, &[probe("functions", false)]).is_ok(),
"an explicit null must not be read as a declaration"
);
}
#[test]
fn a_section_switched_off_is_not_refused() {
let raw = serde_json::json!({ "types": [], "rest_config": { "enabled": false } });
assert!(
super::loader::refuse_unservable_sections(
&raw,
&[probe_with(
"rest_config",
false,
super::loader::Activity::EnabledFlag
)]
)
.is_ok(),
"a disabled section asks for nothing to run"
);
}
#[test]
fn a_section_switched_on_is_refused() {
let raw = serde_json::json!({ "types": [], "rest_config": { "enabled": true } });
assert!(
super::loader::refuse_unservable_sections(
&raw,
&[probe_with(
"rest_config",
false,
super::loader::Activity::EnabledFlag
)]
)
.is_err(),
"an enabled section this build cannot serve must be refused"
);
}
#[test]
fn an_empty_definitions_list_is_not_refused() {
let raw = serde_json::json!({ "types": [], "functions": { "definitions": [] } });
assert!(
super::loader::refuse_unservable_sections(&raw, &[probe("functions", false)]).is_ok(),
"a functions section with nothing in it starts nothing"
);
}
#[test]
fn sources_are_refused_only_when_one_is_enabled() {
let off = serde_json::json!({
"types": [], "sources": [{ "name": "a", "enabled": false }]
});
let on = serde_json::json!({
"types": [],
"sources": [{ "name": "a", "enabled": false }, { "name": "b", "enabled": true }]
});
let section = probe_with("sources", false, super::loader::Activity::AnyEntryEnabled);
assert!(
super::loader::refuse_unservable_sections(&off, &[section]).is_ok(),
"all-disabled sources start no poller"
);
assert!(
super::loader::refuse_unservable_sections(&on, &[section]).is_err(),
"one enabled source among disabled ones must still be refused"
);
}
#[test]
fn every_gated_section_names_a_real_feature() {
let manifest = include_str!("../../Cargo.toml");
for section in super::loader::gated_sections() {
assert!(
manifest.contains(&format!("\n{} = [", section.feature))
|| manifest.contains(&format!("\n{} = {{", section.feature)),
"`{}` names feature `{}`, which is not declared in fraiseql-server/Cargo.toml",
section.name,
section.feature
);
}
}
#[test]
fn every_listed_section_is_checked_not_only_the_first() {
let raw = serde_json::json!({ "types": [], "sources": [{ "name": "s", "enabled": true }] });
let err = super::loader::refuse_unservable_sections(
&raw,
&[
probe("functions", false),
probe_with("sources", false, super::loader::Activity::AnyEntryEnabled),
],
)
.expect_err("the second entry must be checked too");
let SchemaLoadError::ValidationError(message) = err else {
panic!("expected ValidationError")
};
assert!(
message.contains("sources"),
"the refusal must name the section found: {message}"
);
}
#[tokio::test]
async fn test_schema_with_realtime_key_is_ignored() {
let json = r#"{
"types": [
{"name": "Post", "sql_source": "t_posts"},
{"name": "Comment", "sql_source": "t_comments"}
],
"realtime": {
"enabled": true,
"entities": ["Post", "Comment"],
"max_connections_per_context": 50
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.unwrap();
assert_eq!(extended.schema.types.len(), 2);
}
#[tokio::test]
async fn test_schema_without_realtime_key_loads_clean() {
let file = write_schema(minimal_schema());
let loader = CompiledSchemaLoader::new(file.path());
loader.load_extended().await.unwrap();
}
#[tokio::test]
async fn test_schema_realtime_key_with_unknown_entity_is_ignored() {
let json = r#"{
"types": [{"name": "Post", "sql_source": "t_posts"}],
"realtime": {
"enabled": true,
"entities": ["Post", "Ghost"]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let result = loader.load_extended().await;
assert!(
result.is_ok(),
"realtime section must be ignored, not validated, got {result:?}"
);
}
#[cfg(feature = "functions-runtime")]
#[tokio::test]
async fn test_schema_full_loads_all_sections() {
let json = r#"{
"types": [{"name": "User", "sql_source": "t_users"}],
"functions": {
"module_dir": "/functions",
"definitions": [
{"name": "on_create", "trigger": "after:mutation:createUser", "runtime": "Wasm"}
]
},
"realtime": {
"enabled": true,
"entities": ["User"]
}
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let extended = loader.load_extended().await.unwrap();
assert!(extended.functions.is_some());
}
#[tokio::test]
async fn test_schema_unknown_sections_ignored() {
let json = r#"{
"types": [],
"future_feature": {"some_key": "some_value"},
"another_new_thing": 42
}"#;
let file = write_schema(json);
let loader = CompiledSchemaLoader::new(file.path());
let result = loader.load_extended().await;
assert!(result.is_ok(), "unknown sections should be ignored: {result:?}");
}